diff --git a/.gitignore b/.gitignore index de99955..38b0f48 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,10 @@ yarn-error.log **/Pods/ /vendor/bundle/ +# Swift Package Manager +**/.build/ +**/.swiftpm/ + # Temporary files created by Metro to check the health of the file watcher .metro-health-check* diff --git a/App.tsx b/App.tsx index 5e963b1..ca1f3d4 100644 --- a/App.tsx +++ b/App.tsx @@ -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 ( - - + + + + ); } -function AppContent() { - const safeAreaInsets = useSafeAreaInsets(); +function HaptikaApp(): React.JSX.Element { + const cameraPermission = usePPGCamera(); + const measurement = usePPGMeasurement(); + const [onboardingStatus, setOnboardingStatus] = + useState('loading'); + const [onboardingAnswers, setOnboardingAnswers] = useState< + OnboardingAnswers | undefined + >(); + const [appView, setAppView] = useState('measurement'); + const [includeHealthKit, setIncludeHealthKit] = useState(false); + const [sensitiveContact, setSensitiveContact] = useState(true); + const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>( + 'front', + ); + const [atRestSelection, setAtRestSelection] = useState(null); + const [history, setHistory] = useState([]); + const [watchHistory, setWatchHistory] = useState([]); + const [watchRegulationSessions, setWatchRegulationSessions] = useState([]); + const [watchBaseline, setWatchBaseline] = useState(null); + const [watchConnection, setWatchConnection] = useState(EMPTY_WATCH_CONNECTION); + const [saveMindAndBodyWorkouts, setSaveMindAndBodyWorkouts] = useState(true); + const [retentionPolicy, setRetentionPolicy] = + useState('forever'); + const [baselineProgress, setBaselineProgress] = + useState(null); + const [currentMeasurement, setCurrentMeasurement] = + useState(null); + const [exerciseProtocol, setExerciseProtocol] = + useState('coherence55'); + const [pendingProtocolIntent, setPendingProtocolIntent] = + useState(null); + const [regulationBeforeMeasurement, setRegulationBeforeMeasurement] = + useState(null); + const [pendingRegulationSession, setPendingRegulationSession] = + useState(null); + const [pendingCoherenceSessionId, setPendingCoherenceSessionId] = useState< + string | null + >(null); + const [regulationComparison, setRegulationComparison] = + useState(null); + const [regulationSessions, setRegulationSessions] = useState< + RegulationSession[] + >([]); + const [selfReportState, setSelfReportState] = + useState('idle'); + const [finalizationState, setFinalizationState] = + useState('idle'); + const finalizingResultRef = useRef(false); + const measurementAtRestRef = useRef(false); + const postRegulationMeasurementRef = useRef(false); + + const refreshHistory = useCallback(async (): Promise => { + setHistory(await measurementRepository.getAll()); + }, []); + + const refreshWatchData = useCallback(async (): Promise => { + 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 => { + const result = await baselineRepository.calculate({ + measurementHourLocal: new Date().getHours(), + cameraPosition, + }); + setBaselineProgress(result); + return result; + }, [cameraPosition]); + + const refreshRegulationSessions = useCallback(async (): Promise => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 = { + 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 => { + const tombstone = await watchRepository.deleteMeasurement(measurementId); + transferWatchTombstone(tombstone); + await refreshWatchData(); + }, [refreshWatchData, transferWatchTombstone]); + + const clearMeasurementData = useCallback(async (): Promise => { + 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 => { + await measurementRepository.setRetentionPolicy(policy); + setRetentionPolicy(policy); + await Promise.all([refreshHistory(), refreshBaseline()]); + }, + [refreshBaseline, refreshHistory], + ); + + if (onboardingStatus === 'loading') { + return ( + + + + ); + } + + if (onboardingStatus === 'required') { + return ( + { + setOnboardingAnswers(answers); + setOnboardingStatus('completed'); + }} + /> + ); + } + + if (appView === 'history') { + return ( + { + setRegulationBeforeMeasurement(null); + setPendingRegulationSession(null); + setAppView('measurement'); + }} + /> + ); + } + + if (appView === 'cardiacMirror') { + return setAppView('measurement')} />; + } + + if (appView === 'hapticExercise') { + return ( + 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 ( + { + 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 ( + + ); + } + + if (state === 'completed') { + if (currentMeasurement === null) { + if (finalizationState === 'failed') { + return ( + setFinalizationState('idle')} + onBack={backToPreparation} + /> + ); + } + return ( + + + + ); + } + return ( + + ); + } + + if ( + state === 'invalid' || + state === 'thermalStop' || + state === 'interrupted' || + state === 'error' + ) { + return ( + + ); + } return ( - - - + 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, }, }); diff --git a/Docs/Haptika_Passation_Projet_Watch_2026-07-30.docx b/Docs/Haptika_Passation_Projet_Watch_2026-07-30.docx new file mode 100644 index 0000000..7d15ffe Binary files /dev/null and b/Docs/Haptika_Passation_Projet_Watch_2026-07-30.docx differ diff --git a/README.md b/README.md index 3e2c3f8..3f5bed6 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,269 @@ -This is a new [**React Native**](https://reactnative.dev) project, bootstrapped using [`@react-native-community/cli`](https://github.com/react-native-community/cli). +# Haptika — PPG caméra iPhone -# Getting Started +Prototype React Native iPhone-only qui réalise une mesure PPG de contact avec la caméra arrière et le flash, estime un BPM et une PRV expérimentale lorsque le signal le permet, construit une baseline personnelle, puis affiche séparément un indice d’activation potentielle et sa confiance. -> **Note**: Make sure you have completed the [Set Up Your Environment](https://reactnative.dev/docs/set-up-your-environment) guide before proceeding. +L’application fonctionne hors ligne. Aucune frame, image ou vidéo n’est enregistrée ni envoyée. HealthKit est facultatif, en lecture seule, et ne remplace jamais la mesure caméra actuelle. -## Step 1: Start Metro +## Faisabilité vérifiée -First, you will need to run **Metro**, the JavaScript build tool for React Native. +Le prototype est réalisable sur iPhone physique avec des limites importantes : -To start the Metro dev server, run the following command from the root of your React Native project: +- VisionCamera 5 expose les frames iOS à un plugin Nitro Swift sans faire traverser les pixels à JavaScript. +- Les timestamps proviennent du `CMSampleBuffer` et ne sont pas supposés réguliers. +- Le format YUV bi-planar est traité directement à faible résolution, avec un chemin BGRA de secours dans l’extracteur. +- HealthKit expose bien `heartRate`, `restingHeartRate`, `heartRateVariabilitySDNN` et les séries de battements existantes. +- Une app iPhone ne peut pas demander à une Apple Watch ou à HealthKit de produire une nouvelle série beat-to-beat. Cette donnée reste donc historique et facultative. +- Une caméra ne mesure pas la pression physique du doigt. Le message « relâchez légèrement » repose uniquement sur une heuristique de saturation et d’aplatissement du signal. +- La mesure caméra fournit une PRV expérimentale, pas une HRV ECG clinique. -```sh -# Using npm -npm start +Références officielles principales : -# OR using Yarn -yarn start +- [React Native 0.86](https://reactnative.dev/blog/2026/06/11/react-native-0.86) +- [VisionCamera — Native Frame Processor Plugins](https://visioncamera.margelo.com/docs/native-frame-processor-plugins) +- [VisionCamera — Frame Output](https://visioncamera.margelo.com/docs/frame-output) +- [React Native Worklets — installation](https://docs.swmansion.com/react-native-worklets/docs/fundamentals/getting-started/) +- [Apple — HKHeartbeatSeriesQuery](https://developer.apple.com/documentation/healthkit/hkheartbeatseriesquery) +- [Apple — HRV SDNN HealthKit](https://developer.apple.com/documentation/healthkit/hkquantitytypeidentifier/heartratevariabilitysdnn) +- [Apple — état thermique](https://developer.apple.com/documentation/foundation/processinfo/thermalstate-swift.property) +- [Apple — privacy manifests](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files) + +## Versions verrouillées + +| Élément | Version | +|---|---:| +| React Native | 0.86.0 | +| React | 19.2.3 | +| VisionCamera | 5.1.0 | +| VisionCamera Worklets | 5.1.0 | +| React Native Worklets | 0.10.2 | +| Nitro Modules / Nitrogen | 0.36.1 | +| Nitro Image | 0.15.1 | +| TypeScript | ^5.8.3, strict | +| iOS minimum | 16.0 | +| Node.js | >= 22.11 | + +VisionCamera V5 n’utilise plus l’ancien `useFrameProcessor`, `FrameProcessorPluginRegistry` ou `react-native-worklets-core`. Le code utilise `useFrameOutput`, `react-native-worklets` et un HybridObject Nitro typé. + +## Architecture + +```text +React Native / TypeScript + UI, permissions, machine à états, baseline, indice, confiance, + auto-report, historique et orchestration + │ + │ métriques compactes à ~6 Hz + ▼ +VisionCamera Worklet + Nitro Swift + CVPixelBuffer → ROI → statistiques couleur → contact → buffer numérique + │ + │ fin de mesure + ▼ +Signal Swift déterministe + timestamps → gaps → resampling → detrend/MAD → FIR → canaux + → pics/IPI → BPM temporel + autocorrélation → PRV → qualité + +HealthKit Swift (séparé) + historique sourcé/daté, lecture seule, poids du score principal = 0 ``` -## Step 2: Build and run your app +### Couche React Native -With Metro running, open a new terminal window/pane from the root of your React Native project, and use one of the following commands to build and run your Android or iOS app: +- `src/features/ppg/camera/` : caméra VisionCamera, worklet et arrêt fail-safe du flash. +- `src/features/ppg/measurement/` : machine à états, configuration et coordination native. +- `src/features/ppg/stress/` : baseline robuste, indice potentiel, confiance et interprétation. +- `src/features/ppg/health/` : modèle HealthKit et évaluation de récence. +- `src/features/ppg/storage/` : mesures numériques, réglages et auto-report locaux. +- `src/features/ppg/screens/` et `components/` : préparation, mesure, résultat, historique et composants d’état. +- `src/features/ppg/tests/` : tests Jest des transitions et calculs métier. -### Android +### Couche Swift / Nitro -```sh -# Using npm -npm run android +Le package local `packages/react-native-haptika-ppg` contient : -# OR using Yarn -yarn android +- la spec TypeScript Nitrogen `src/specs/HaptikaPPG.nitro.ts` ; +- les bindings générés dans `nitrogen/generated/` ; +- `PPGFrameMetricsExtractor.swift` pour YUV/BGRA et ROI ; +- `PPGFingerContactDetector.swift` pour le contact multi-caractéristiques avec hystérésis ; +- `HybridHaptikaPPGFrameProcessor.swift` pour le cycle de mesure et le buffer numérique ; +- `ios/Signal/` pour le pipeline final ; +- `PPGThermalMonitor.swift` pour `ProcessInfo.thermalState` ; +- `HybridHaptikaHealthKit.swift` pour HealthKit en lecture seule. + +Les bindings Nitro générés remplacent un bridge Objective-C manuel. C’est l’architecture officielle requise par VisionCamera 5 pour un plugin de frame natif. + +## Flux d’une frame + +1. VisionCamera négocie 30 FPS et une sortie cible 640 × 480. +2. Le worklet appelle le HybridObject Swift et libère toujours la frame dans `finally`. +3. Swift récupère temporairement le `CMSampleBuffer` et son `CVPixelBuffer`. +4. Le buffer est verrouillé en lecture seule et systématiquement déverrouillé avec `defer`. +5. Une grille centrale est échantillonnée directement dans les plans Y et UV. Le chemin BGRA est également supporté. +6. Seuls les moyennes/variances couleur, la luminance, la saturation, le contact et le mouvement sont conservés. +7. Le `CMSampleBuffer` et le `CVPixelBuffer` ne sont jamais retenus après le callback. +8. Une mise à jour compacte est envoyée à React Native tous les cinq frames, soit environ 6 Hz. + +## Pipeline du signal + +- validation des valeurs finies et des timestamps monotones ; +- diagnostic FPS, dispersion, frames manquantes et lacunes longues ; +- interpolation uniquement des petites lacunes ; +- masque invalide autour des interruptions ; +- rééchantillonnage régulier à 30 Hz ; +- suppression de tendance et normalisation médiane/MAD ; +- filtre FIR passe-bande recalculé pour le sample rate réel ; +- comparaison rouge, vert, luminance et blend normalisé ; +- détection adaptative des pics dans les deux polarités ; +- raffinement temporel et rejet des doubles pics/intervalles aberrants ; +- BPM temporel par médiane des IPI ; +- estimation indépendante par autocorrélation ; +- publication du BPM seulement si les méthodes et la qualité concordent ; +- RMSSD/SDNN des IPI uniquement comme PRV expérimentale ; +- score de qualité global et causes d’invalidation explicites. + +Le FIR Swift porte sur environ quatre canaux × 1 800 échantillons. Cette charge est faible et déterministe ; Accelerate/vDSP n’a pas été ajouté sans bénéfice mesuré. La convolution est isolée et pourra être remplacée par vDSP après profilage sur les modèles d’iPhone ciblés. + +## Baseline, indice et confiance + +- seules les mesures caméra valides, au repos et de qualité ≥ 0,65 alimentent la baseline ; +- le contexte « au repos » est confirmé avant chaque capture ; une mesure hors repos peut afficher le BPM et la qualité, mais aucun indice comparatif ; +- médiane et MAD limitent l’effet des valeurs extrêmes ; +- avant cinq mesures comparables, aucun indice personnalisé n’est affiché ; +- de cinq à neuf mesures antérieures, le résultat est provisoire et sa confiance plafonnée ; +- à partir de dix mesures, le statut peut devenir personnalisé ; +- le BPM et le log-RMSSD caméra sont normalisés séparément ; +- si la PRV est indisponible, l’indice utilise uniquement le BPM et la confiance est fortement réduite ; +- `healthKitWeight` est validé comme strictement égal à zéro ; +- HealthKit peut seulement contribuer à la cohérence contextuelle de la confiance. + +## HealthKit + +L’autorisation est demandée séparément et n’empêche jamais la caméra de fonctionner. Le module demande uniquement la lecture de : + +- fréquence cardiaque ; +- fréquence cardiaque au repos ; +- HRV SDNN ; +- série de battements existante. + +Chaque valeur conserve son unité, ses dates, son application source, son bundle source et son appareil lorsqu’il est disponible. Apple ne permet pas de distinguer un refus de lecture d’un historique vide : l’app mémorise donc seulement que la demande s’est terminée, jamais un faux statut « autorisé ». Une valeur ancienne est marquée comme contexte obsolète et n’est jamais affichée comme une valeur actuelle. SDNN HealthKit, PRV caméra, BPM caméra et fréquence au repos restent des métriques distinctes. + +Pour la minimisation, chaque mesure persistée conserve au plus la dernière fréquence cardiaque historique, la dernière fréquence au repos et le dernier SDNN accessibles. La confiance contextuelle est recalculée à partir de ce même sous-ensemble ; les 100 échantillons que la requête native peut consulter transitoirement ne sont pas dupliqués dans l’historique local. + +## Installation + +## Module 7 — Miroir cardiaque + +Le miroir cardiaque est une expérience différentielle isolée du pipeline PPG. +Son pattern haptique propriétaire est identifié dans le code par +`HAPTIKA.CARDIAC_MIRROR_BEAT` et versionné `1.0.0`. Aucune promesse de latence +par rapport à un battement biologique n’est faite : les API Workout publiques +fournissent des mises à jour de fréquence, pas des événements battement par battement. + +Deux modes sont visibles dans l’interface : + +- **Connecté · Watch+** : la Watch mesure sans vibration et transmet ses mises à + jour de fréquence. L’iPhone reconstruit le rythme visuel et Core Haptics. +- **Estimation · iPhone** : utilise une fréquence de départ saisie par + l’utilisateur et applique une décélération dynamique de 1 à 2 BPM/minute. + Ce mode est explicitement dégradé et ne mesure pas chaque battement. + +La cible SwiftUI watchOS 11 fonctionne aussi hors connexion, puis synchronise +ses résumés et séances avec l’iPhone appairé via WatchConnectivity. + +Une bibliothèque de régulation est également disponible sans Watch ni caméra. +Elle reprend les quatre protocoles du Sprint 0 avec une interface bleu nuit et +des ondes visuelles synchronisées : + +- Cohérence 5–5, pendant 5 minutes ; +- Expiration 4–6, pendant 3 minutes ; +- SOS tension, pendant environ 1 minute ; +- Micro-ancrage, pendant 37 secondes. + +Les patterns sont produits par Core Haptics et non par une vibration JavaScript +générique. Ces exercices sont des guidages sensoriels, pas des mesures ni des +protocoles médicaux de traitement du stress. + +Prérequis : macOS, Xcode 26 ou version compatible RN 0.86, Node 22+, CocoaPods 1.16+, compte Apple Developer pour HealthKit et un iPhone physique. + +```bash +cd HaptikaApp +npm ci +npm run native:specs +cd ios +pod install +cd .. +open ios/HaptikaApp.xcworkspace ``` -### iOS +Dans Xcode : -For iOS, remember to install CocoaPods dependencies (this only needs to be run on first clone or after updating native deps). +1. sélectionner la cible `HaptikaApp` ; +2. choisir l’équipe de signature ; +3. vérifier le bundle ID `com.haptika.iphone` ; +4. vérifier la capability HealthKit et `HaptikaApp.entitlements` ; +5. choisir un iPhone physique ; +6. lancer l’application. -The first time you create a new project, run the Ruby bundler to install CocoaPods itself: +Le projet inclut déjà `NSCameraUsageDescription`, `NSHealthShareUsageDescription`, l’entitlement HealthKit, la protection de fichiers complète et `PrivacyInfo.xcprivacy`. -```sh -bundle install +## Tests + +```bash +cd HaptikaApp +npm run typecheck +npm run lint +npm run test:ci + +cd packages/react-native-haptika-ppg +swift test + +cd ../../../SignalProcessingKit +swift test +swift test -c release ``` -Then, and every time you update your native dependencies, run: +État de validation du prototype : 42 tests Jest, 3 tests Swift CVPixelBuffer et 17 tests Swift du pipeline signal passent. Le workspace iOS compile pour iPhone Simulator ; la calibration PPG et le cycle réel du flash restent à valider sur appareil physique. -```sh -bundle exec pod install -``` +Le simulateur valide l’UI et le build, mais pas le contact PPG, le comportement réel du flash, la chauffe, les interruptions matérielles ou les données HealthKit de production. -For more information, please visit [CocoaPods Getting Started guide](https://guides.cocoapods.org/using/getting-started.html). +### Procédure iPhone physique -```sh -# Using npm -npm run ios +1. retirer une coque qui masque la caméra ou le flash ; +2. autoriser la caméra ; +3. laisser HealthKit désactivé pour le premier test ; +4. poser doucement le doigt sur la caméra arrière et le flash ; +5. rester immobile et respirer normalement pendant la stabilisation puis l’acquisition ; +6. vérifier l’arrêt immédiat du flash après succès, erreur, annulation et mise en arrière-plan ; +7. répéter à 60, 75 et 120 BPM avec un simulateur optique ou une référence externe si disponible ; +8. tester plusieurs modèles d’iPhone et niveaux de batterie/température ; +9. répéter au moins cinq à dix mesures au repos pour évaluer la stabilité personnelle ; +10. comparer les résultats à une référence validée avant toute affirmation de précision. -# OR using Yarn -yarn ios -``` +## Confidentialité -If everything is set up correctly, you should see your new app running in the Android Emulator, iOS Simulator, or your connected device. +- [x] aucune frame enregistrée ou transférée à JavaScript ; +- [x] aucune photo/vidéo écrite dans le conteneur ou la photothèque ; +- [x] aucune dépendance réseau fonctionnelle ; +- [x] métriques numériques et auto-report stockés localement ; +- [x] protection par défaut `NSFileProtectionComplete` ; +- [x] HealthKit en lecture seule et facultatif ; +- [x] contexte HealthKit minimal, daté et sourcé (au plus trois métriques par mesure) ; +- [x] manifeste de confidentialité sans tracking ni collecte distante ; +- [ ] audit d’archive Xcode à refaire pour chaque mise à jour de dépendance ; +- [ ] politique de suppression/export des données à finaliser avant production. -This is one way to run your app — you can also build it directly from Android Studio or Xcode. +## Checklist App Store -## Step 3: Modify your app +- [ ] tester et calibrer sur les iPhone réellement supportés ; +- [ ] fournir une méthodologie de validation et des limites de précision ; +- [ ] conserver partout le vocabulaire expérimental/non médical ; +- [ ] ne jamais afficher « stress détecté », « diagnostic » ou « crise » ; +- [ ] vérifier les captures App Store et textes de permission ; +- [ ] vérifier le rapport de confidentialité de l’archive ; +- [ ] remplir les réponses App Privacy conformément au traitement strictement local ; +- [ ] vérifier que HealthKit n’est pas utilisé pour publicité, profilage ou revente ; +- [ ] confirmer l’arrêt de la torche dans tous les parcours d’interruption ; +- [ ] effectuer une revue accessibilité et localisation finale. -Now that you have successfully run the app, let's make changes! +## Limites scientifiques et techniques -Open `App.tsx` in your text editor of choice and make some changes. When you save, your app will automatically update and reflect these changes — this is powered by [Fast Refresh](https://reactnative.dev/docs/fast-refresh). - -When you want to forcefully reload, for example to reset the state of your app, you can perform a full reload: - -- **Android**: Press the R key twice or select **"Reload"** from the **Dev Menu**, accessed via Ctrl + M (Windows/Linux) or Cmd ⌘ + M (macOS). -- **iOS**: Press R in iOS Simulator. - -## Congratulations! :tada: - -You've successfully run and modified your React Native App. :partying_face: - -### Now what? - -- If you want to add this new React Native code to an existing application, check out the [Integration guide](https://reactnative.dev/docs/integration-with-existing-apps). -- If you're curious to learn more about React Native, check out the [docs](https://reactnative.dev/docs/getting-started). - -# Troubleshooting - -If you're having issues getting the above steps to work, see the [Troubleshooting](https://reactnative.dev/docs/troubleshooting) page. - -# Learn More - -To learn more about React Native, take a look at the following resources: - -- [React Native Website](https://reactnative.dev) - learn more about React Native. -- [Getting Started](https://reactnative.dev/docs/environment-setup) - an **overview** of React Native and how setup your environment. -- [Learn the Basics](https://reactnative.dev/docs/getting-started) - a **guided tour** of the React Native **basics**. -- [Blog](https://reactnative.dev/blog) - read the latest official React Native **Blog** posts. -- [`@facebook/react-native`](https://github.com/facebook/react-native) - the Open Source; GitHub **repository** for React Native. +Ce prototype ne constitue ni un dispositif médical, ni un ECG, ni une mesure clinique du stress ou du système nerveux autonome. Les artefacts de mouvement, la pigmentation, la position du doigt, la géométrie caméra/flash, l’exposition automatique, la température et le modèle d’iPhone influencent la mesure. La PRV caméra ne doit pas être assimilée à une HRV ECG. Les écarts isolés sont insuffisants : l’interprétation repose sur des mesures répétées, une baseline personnelle, le contexte et un auto-report séparé. diff --git a/__tests__/App.test.tsx b/__tests__/App.test.tsx index e532f70..ea3a0d8 100644 --- a/__tests__/App.test.tsx +++ b/__tests__/App.test.tsx @@ -4,6 +4,45 @@ import React from 'react'; import ReactTestRenderer from 'react-test-renderer'; + +jest.mock('react-native-vision-camera', () => ({ + Camera: () => null, + useCameraDevice: () => undefined, + useCameraPermission: () => ({ + hasPermission: true, + requestPermission: jest.fn(async () => true), + }), + useFrameOutput: () => ({}), +})); + +jest.mock('react-native-worklets', () => ({ + scheduleOnRN: jest.fn(), +})); + +jest.mock('react-native-haptika-ppg', () => ({ + haptikaPPG: { + startMeasurement: jest.fn(), + processFrame: jest.fn(), + finishMeasurement: jest.fn(), + cancelMeasurement: jest.fn(), + getThermalState: jest.fn(() => 'nominal'), + }, + haptikaHealthKit: { + isAvailable: jest.fn(() => false), + requestReadAuthorization: jest.fn(async () => false), + fetchContext: jest.fn(), + fetchLatestHeartbeatSeries: jest.fn(), + }, +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(async () => null), + setItem: jest.fn(async () => undefined), + }, +})); + import App from '../App'; test('renders correctly', async () => { diff --git a/babel.config.js b/babel.config.js index f7b3da3..8ba8eb6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,4 @@ module.exports = { presets: ['module:@react-native/babel-preset'], + plugins: ['react-native-worklets/plugin'], }; diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..f7a36d4 --- /dev/null +++ b/design-qa.md @@ -0,0 +1,45 @@ +# Design QA — écrans Figma 123 + +## Références + +- Figma 123:527 — accueil de mesure +- Figma 123:465 — préparation +- Figma 123:404 — mesure en cours +- Figma 123:320 — résultat +- Comparaison combinée : `qa/figma-123/comparison-all.jpg` + +## Environnement de contrôle + +- Application : React Native 0.86, build Debug iOS +- Simulateur : iPhone 17 Pro Max, iOS 26.5 +- Capture optimisée : 368 × 800 px +- Les états mesure et résultat ont été contrôlés avec des données de prévisualisation réalistes, retirées après la capture. +- La marge supérieure supplémentaire de l’application correspond à la zone sûre réelle de la Dynamic Island, absente des cadres Figma. + +## Comparaison visuelle + +| Écran | Contrôles | Résultat | +| --- | --- | --- | +| Accueil | hiérarchie, axe, silhouette, carte caméra, CTA, navigation | conforme | +| Préparation | en-tête, photographie source, quatre consignes, alerte, CTA | conforme ; contenu défilable sur les hauteurs réduites | +| Mesure | en-tête, statut direct, halo, signal, arrêt, progression | conforme | +| Résultat | score, axe, métriques, recommandation, actions | conforme et alimenté par les vraies données | + +## Contrôles fonctionnels + +- [x] Les CTA accueil → préparation → mesure sont actifs. +- [x] Arrêter et Annuler interrompent la mesure. +- [x] Lancer la séance et Terminer conservent leurs callbacks produit. +- [x] Le menu, les réglages et la navigation basse restent actifs. +- [x] Les images sont les assets exportés du fichier Figma. +- [x] Le rendu respecte la zone sûre des iPhone avec Dynamic Island. +- [x] Aucun mode de démonstration n’est conservé dans le code. + +## Validation technique + +- [x] `npm run typecheck` +- [x] `npm run lint` +- [x] `npm run test:ci` — 17 suites, 86 tests +- [x] Build et lancement iOS sur simulateur + +Résultat final : **PASS**. diff --git a/ios/HaptikaApp.xcodeproj/project.pbxproj b/ios/HaptikaApp.xcodeproj/project.pbxproj index a3556c2..c11a55b 100644 --- a/ios/HaptikaApp.xcodeproj/project.pbxproj +++ b/ios/HaptikaApp.xcodeproj/project.pbxproj @@ -7,26 +7,120 @@ objects = { /* Begin PBXBuildFile section */ + 09FC73E86512568662FCE68E /* WatchMeasurementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */; }; 0C80B921A6F3F58F76C31292 /* libPods-HaptikaApp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */; }; + 114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */ = {isa = PBXBuildFile; fileRef = 9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 140882F922D2F73D8BDA7471 /* WatchResourceArbiter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */; }; + 1497DAF82CAFD24F955F103F /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; + 1BD3026EBCA93A2454A9B571 /* WatchStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */; }; + 2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */; }; + 4BCA2B05E3DE6C7AAB303B80 /* WatchAppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 111670E33699C47D3A93AC41 /* WatchAppModel.swift */; }; + 4E8C31BD8AC8CA86BADD6118 /* RegulationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B35DC921291116A06F055C /* RegulationProtocol.swift */; }; + 52352554AE62A5DBE62E1F80 /* WatchCoherenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D79C037057C356A5804C171 /* WatchCoherenceView.swift */; }; + 57EE4CD6E860841A6FBE3E31 /* WatchHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; + 77BA4CD21D33B597ECD2FDE7 /* WatchBeforeAfterFlowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */; }; + 78F476213C9C09971A280386 /* WatchModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32D728BA3B4A37EEAC4733EB /* WatchModels.swift */; }; + 7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D442ED95A5832B6D76BE3836 /* WatchRootView.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + 85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */; }; + 8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E742A36D6693717080FD19DB /* WatchBaseline.swift */; }; + 93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */; }; + A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */; }; + A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */; }; + B823EAA6372CFAB8566CB32C /* HaptikaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */; }; + C0DCCDA0101428B2C431DF72 /* WatchCardiacMirrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */; }; + CAE5DC58530D8CEB55E32C3E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E5969515006E0E77675C21 /* Foundation.framework */; }; + CC2201B4509C4CF1F751EDF3 /* WatchTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = A845A00D463125569C206227 /* WatchTheme.swift */; }; + D7C2CEDAA071025E0CFBA522 /* WatchMeasurementValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */; }; + E0CE73E6D3D989EDA8C0E890 /* WatchHealthMeasurementService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */; }; + F7A804FDCA84B13EE87ED3FC /* WatchSyncEnvelope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */; }; + F8117E6E1721ACDD6B88F7C1 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 78E5969515006E0E77675C21 /* Foundation.framework */; }; + FACC8C666D0D4D58E2E18B40 /* Haptika Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 1DC509C0BED42BBA3A0DAE4E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AB289AE8AED259F5623A2E42; + remoteInfo = "Haptika Watch App"; + }; + 2FE1ADCC102A2D599E060B6D /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = AB289AE8AED259F5623A2E42; + remoteInfo = "Haptika Watch App"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + EFAEDF4DE57326DECDBE233E /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 16; + files = ( + FACC8C666D0D4D58E2E18B40 /* Haptika Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ + 0142DCF485AB5826009D8407 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchResourceArbiter.swift; sourceTree = ""; }; + 111670E33699C47D3A93AC41 /* WatchAppModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchAppModel.swift; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* HaptikaApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HaptikaApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = HaptikaApp/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HaptikaApp/Info.plist; sourceTree = ""; }; 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = HaptikaApp/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HaptikaWatchApp.swift; sourceTree = ""; }; + 32D728BA3B4A37EEAC4733EB /* WatchModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModels.swift; sourceTree = ""; }; 3B4392A12AC88292D35C810B /* Pods-HaptikaApp.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.debug.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.debug.xcconfig"; sourceTree = ""; }; + 3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchMeasurementValidator.swift; sourceTree = ""; }; + 45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCardiacMirrorView.swift; sourceTree = ""; }; 5709B34CF0A7D63546082F79 /* Pods-HaptikaApp.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HaptikaApp.release.xcconfig"; path = "Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HaptikaApp.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchMeasurementView.swift; sourceTree = ""; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HaptikaApp/AppDelegate.swift; sourceTree = ""; }; + 78E5969515006E0E77675C21 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/WatchOS.platform/Developer/SDKs/WatchOS11.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + 7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchSyncEnvelope.swift; sourceTree = ""; }; + 816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBeforeAfterFlowView.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = HaptikaApp/LaunchScreen.storyboard; sourceTree = ""; }; + 8D79C037057C356A5804C171 /* WatchCoherenceView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCoherenceView.swift; sourceTree = ""; }; + 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Haptika Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */ = {isa = PBXFileReference; includeInIndex = 1; path = "regulation-protocols.v1.json"; sourceTree = ""; }; + A845A00D463125569C206227 /* WatchTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchTheme.swift; sourceTree = ""; }; + A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HaptikaWatchTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = HaptikaWatch.entitlements; sourceTree = ""; }; + CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchConnectivityService.swift; sourceTree = ""; }; + CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = ""; }; + D442ED95A5832B6D76BE3836 /* WatchRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = ""; }; + D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchExercisesView.swift; sourceTree = ""; }; + D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHistoryView.swift; sourceTree = ""; }; + D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHealthMeasurementService.swift; sourceTree = ""; }; + E6B35DC921291116A06F055C /* RegulationProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RegulationProtocol.swift; sourceTree = ""; }; + E742A36D6693717080FD19DB /* WatchBaseline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBaseline.swift; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCoreTests.swift; sourceTree = ""; }; + FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHapticService.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 0F146085D8726D5484AC45CF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F8117E6E1721ACDD6B88F7C1 /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -35,9 +129,32 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + B6BFDAFC89FF560DC3940EE4 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CAE5DC58530D8CEB55E32C3E /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0AF569BB6E060433D5185B95 /* Core */ = { + isa = PBXGroup; + children = ( + E6B35DC921291116A06F055C /* RegulationProtocol.swift */, + E742A36D6693717080FD19DB /* WatchBaseline.swift */, + 3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */, + 32D728BA3B4A37EEAC4733EB /* WatchModels.swift */, + 0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */, + CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */, + 7E2F44A1D3FF2919ED02C7BC /* WatchSyncEnvelope.swift */, + ); + name = Core; + path = Core; + sourceTree = ""; + }; 13B07FAE1A68108700A75B9A /* HaptikaApp */ = { isa = PBXGroup; children = ( @@ -55,10 +172,20 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */, + A545FB6B835260DD2FAABBBD /* watchOS */, ); name = Frameworks; sourceTree = ""; }; + 47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */ = { + isa = PBXGroup; + children = ( + ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */, + ); + name = HaptikaWatchTests; + path = HaptikaWatchTests; + sourceTree = ""; + }; 832341AE1AAA6A7D00B99B32 /* Libraries */ = { isa = PBXGroup; children = ( @@ -74,6 +201,9 @@ 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, BBD78D7AC51CEA395F1C20DB /* Pods */, + A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */, + 47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */, + E900FA2623A3C868FA97EFEE /* HaptikaWatch */, ); indentWidth = 2; sourceTree = ""; @@ -84,10 +214,40 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* HaptikaApp.app */, + 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */, + A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */, ); name = Products; sourceTree = ""; }; + A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */ = { + isa = PBXGroup; + children = ( + 9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */, + ); + name = HaptikaShared; + path = ../shared; + sourceTree = ""; + }; + A545FB6B835260DD2FAABBBD /* watchOS */ = { + isa = PBXGroup; + children = ( + 78E5969515006E0E77675C21 /* Foundation.framework */, + ); + name = watchOS; + sourceTree = ""; + }; + B06321B98A4E85D4005C94CB /* Services */ = { + isa = PBXGroup; + children = ( + CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */, + FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */, + D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */, + ); + name = Services; + path = Services; + sourceTree = ""; + }; BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( @@ -97,6 +257,47 @@ path = Pods; sourceTree = ""; }; + E900FA2623A3C868FA97EFEE /* HaptikaWatch */ = { + isa = PBXGroup; + children = ( + EB11F95E679D64159FCFDE76 /* App */, + 0AF569BB6E060433D5185B95 /* Core */, + B06321B98A4E85D4005C94CB /* Services */, + F1595BA33FD735C10E065549 /* Views */, + AF06B58CBAC27F9902D1B4A5 /* Assets.xcassets */, + 0142DCF485AB5826009D8407 /* Info.plist */, + BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */, + ); + name = HaptikaWatch; + path = HaptikaWatch; + sourceTree = ""; + }; + EB11F95E679D64159FCFDE76 /* App */ = { + isa = PBXGroup; + children = ( + 2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */, + 111670E33699C47D3A93AC41 /* WatchAppModel.swift */, + ); + name = App; + path = App; + sourceTree = ""; + }; + F1595BA33FD735C10E065549 /* Views */ = { + isa = PBXGroup; + children = ( + 816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */, + 45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */, + 8D79C037057C356A5804C171 /* WatchCoherenceView.swift */, + D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */, + D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */, + 5F6CA55B03D1BB7CE459E8E3 /* WatchMeasurementView.swift */, + D442ED95A5832B6D76BE3836 /* WatchRootView.swift */, + A845A00D463125569C206227 /* WatchTheme.swift */, + ); + name = Views; + path = Views; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -111,16 +312,53 @@ 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */, E235C05ADACE081382539298 /* [CP] Copy Pods Resources */, + EFAEDF4DE57326DECDBE233E /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( + 4017CDC02DF1C913443BA87D /* PBXTargetDependency */, ); name = HaptikaApp; productName = HaptikaApp; productReference = 13B07F961A680F5B00A75B9A /* HaptikaApp.app */; productType = "com.apple.product-type.application"; }; + AB289AE8AED259F5623A2E42 /* Haptika Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = C9284034880924E3E8455E96 /* Build configuration list for PBXNativeTarget "Haptika Watch App" */; + buildPhases = ( + 2803498A232C8DD92F5D6055 /* Sources */, + 0F146085D8726D5484AC45CF /* Frameworks */, + 8BA43C7D9C0E69C3DFF61044 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Haptika Watch App"; + productName = "Haptika Watch App"; + productReference = 9DB449283FD6350D75DDC204 /* Haptika Watch App.app */; + productType = "com.apple.product-type.application"; + }; + EA92A2DC9365C1414126C0B1 /* HaptikaWatchTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 774FEB5EB351F846855029C1 /* Build configuration list for PBXNativeTarget "HaptikaWatchTests" */; + buildPhases = ( + CD9C4519B70F549386BFF6FF /* Sources */, + B6BFDAFC89FF560DC3940EE4 /* Frameworks */, + B8FDE984F342D03CB0CC5583 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + CE1B0BDBE98F17187CFC4F67 /* PBXTargetDependency */, + ); + name = HaptikaWatchTests; + productName = HaptikaWatchTests; + productReference = A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -132,6 +370,15 @@ 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1120; }; + AB289AE8AED259F5623A2E42 = { + CreatedOnToolsVersion = 26.0; + ProvisioningStyle = Automatic; + }; + EA92A2DC9365C1414126C0B1 = { + CreatedOnToolsVersion = 26.0; + ProvisioningStyle = Automatic; + TestTargetID = AB289AE8AED259F5623A2E42; + }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */; @@ -148,6 +395,8 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* HaptikaApp */, + AB289AE8AED259F5623A2E42 /* Haptika Watch App */, + EA92A2DC9365C1414126C0B1 /* HaptikaWatchTests */, ); }; /* End PBXProject section */ @@ -159,6 +408,23 @@ files = ( 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 1497DAF82CAFD24F955F103F /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 8BA43C7D9C0E69C3DFF61044 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2754CCE8D2EB3703FA37D055 /* Assets.xcassets in Resources */, + 114BDE84D8AA9241891F063A /* regulation-protocols.v1.json in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + B8FDE984F342D03CB0CC5583 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; @@ -189,10 +455,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-frameworks.sh\"\n"; @@ -228,10 +498,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HaptikaApp/Pods-HaptikaApp-resources.sh\"\n"; @@ -248,8 +522,58 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2803498A232C8DD92F5D6055 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B823EAA6372CFAB8566CB32C /* HaptikaWatchApp.swift in Sources */, + 4BCA2B05E3DE6C7AAB303B80 /* WatchAppModel.swift in Sources */, + 4E8C31BD8AC8CA86BADD6118 /* RegulationProtocol.swift in Sources */, + 8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */, + D7C2CEDAA071025E0CFBA522 /* WatchMeasurementValidator.swift in Sources */, + 78F476213C9C09971A280386 /* WatchModels.swift in Sources */, + 140882F922D2F73D8BDA7471 /* WatchResourceArbiter.swift in Sources */, + 1BD3026EBCA93A2454A9B571 /* WatchStore.swift in Sources */, + F7A804FDCA84B13EE87ED3FC /* WatchSyncEnvelope.swift in Sources */, + A5EF56A31E4A899ADF00A55C /* WatchConnectivityService.swift in Sources */, + A03E880ED4913D3E67FA728C /* WatchHapticService.swift in Sources */, + E0CE73E6D3D989EDA8C0E890 /* WatchHealthMeasurementService.swift in Sources */, + 77BA4CD21D33B597ECD2FDE7 /* WatchBeforeAfterFlowView.swift in Sources */, + C0DCCDA0101428B2C431DF72 /* WatchCardiacMirrorView.swift in Sources */, + 52352554AE62A5DBE62E1F80 /* WatchCoherenceView.swift in Sources */, + 93FBBB939E9FBC3D0DECCC37 /* WatchExercisesView.swift in Sources */, + 57EE4CD6E860841A6FBE3E31 /* WatchHistoryView.swift in Sources */, + 09FC73E86512568662FCE68E /* WatchMeasurementView.swift in Sources */, + 7EE01244D179E8FF19B34AB1 /* WatchRootView.swift in Sources */, + CC2201B4509C4CF1F751EDF3 /* WatchTheme.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + CD9C4519B70F549386BFF6FF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 85CD8DEF4D32F6F143395961 /* WatchCoreTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 4017CDC02DF1C913443BA87D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "Haptika Watch App"; + target = AB289AE8AED259F5623A2E42 /* Haptika Watch App */; + targetProxy = 2FE1ADCC102A2D599E060B6D /* PBXContainerItemProxy */; + }; + CE1B0BDBE98F17187CFC4F67 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = "Haptika Watch App"; + target = AB289AE8AED259F5623A2E42 /* Haptika Watch App */; + targetProxy = 1DC509C0BED42BBA3A0DAE4E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; @@ -257,10 +581,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = HaptikaApp/HaptikaApp.entitlements; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R537R56465; ENABLE_BITCODE = NO; INFOPLIST_FILE = HaptikaApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -271,12 +597,12 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev; PRODUCT_NAME = HaptikaApp; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; @@ -287,9 +613,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = HaptikaApp/HaptikaApp.entitlements; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R537R56465; INFOPLIST_FILE = HaptikaApp/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -300,15 +628,33 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev; PRODUCT_NAME = HaptikaApp; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; + TARGETED_DEVICE_FAMILY = 1; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; }; + 736D5E5CBB3D4C53DB7FEBE2 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = R537R56465; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Haptika Watch App.app/Haptika Watch App"; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Release; + }; 83CBBA201A601CBA00E9B192 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -358,7 +704,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -370,6 +716,10 @@ ); MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); OTHER_CPLUSPLUSFLAGS = ( "$(OTHER_CFLAGS)", "-DFOLLY_NO_CONFIG", @@ -377,8 +727,14 @@ "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", ); + PODFILE_DIR = "$(SRCROOT)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; }; name = Debug; }; @@ -424,7 +780,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.1; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( /usr/lib/swift, "$(inherited)", @@ -435,6 +791,10 @@ "\"$(inherited)\"", ); MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); OTHER_CPLUSPLUSFLAGS = ( "$(OTHER_CFLAGS)", "-DFOLLY_NO_CONFIG", @@ -442,12 +802,89 @@ "-DFOLLY_USE_LIBCPP=1", "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", + "-DRCT_REMOVE_LEGACY_ARCH=1", ); + PODFILE_DIR = "$(SRCROOT)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; VALIDATE_PRODUCT = YES; }; name = Release; }; + A6B4F5957EED7AFABF739D57 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = HaptikaWatch/HaptikaWatch.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R537R56465; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HaptikaWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchkitapp; + PRODUCT_MODULE_NAME = HaptikaWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + VALIDATE_PRODUCT = YES; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Release; + }; + AA137F90108381911B05309E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = R537R56465; + GENERATE_INFOPLIST_FILE = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchtests; + SDKROOT = watchos; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Haptika Watch App.app/Haptika Watch App"; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Debug; + }; + D11D33BA631ABF82C65B2BFC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = HaptikaWatch/HaptikaWatch.entitlements; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = R537R56465; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = HaptikaWatch/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev.watchkitapp; + PRODUCT_MODULE_NAME = HaptikaWatch; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "watchos watchsimulator"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 11.0; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -460,6 +897,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 774FEB5EB351F846855029C1 /* Build configuration list for PBXNativeTarget "HaptikaWatchTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 736D5E5CBB3D4C53DB7FEBE2 /* Release */, + AA137F90108381911B05309E /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HaptikaApp" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -469,6 +915,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + C9284034880924E3E8455E96 /* Build configuration list for PBXNativeTarget "Haptika Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A6B4F5957EED7AFABF739D57 /* Release */, + D11D33BA631ABF82C65B2BFC /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; diff --git a/ios/HaptikaApp.xcodeproj/xcshareddata/xcschemes/Haptika Watch App.xcscheme b/ios/HaptikaApp.xcodeproj/xcshareddata/xcschemes/Haptika Watch App.xcscheme new file mode 100644 index 0000000..9de5915 --- /dev/null +++ b/ios/HaptikaApp.xcodeproj/xcshareddata/xcschemes/Haptika Watch App.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/HaptikaApp.xcworkspace/contents.xcworkspacedata b/ios/HaptikaApp.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..97e1e18 --- /dev/null +++ b/ios/HaptikaApp.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/HaptikaApp/HaptikaApp.entitlements b/ios/HaptikaApp/HaptikaApp.entitlements new file mode 100644 index 0000000..56b99d1 --- /dev/null +++ b/ios/HaptikaApp/HaptikaApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.default-data-protection + NSFileProtectionComplete + com.apple.developer.healthkit + + + diff --git a/ios/HaptikaApp/Info.plist b/ios/HaptikaApp/Info.plist index 627392f..276860c 100644 --- a/ios/HaptikaApp/Info.plist +++ b/ios/HaptikaApp/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - HaptikaApp + Haptika CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -28,14 +28,15 @@ NSAppTransportSecurity - NSAllowsArbitraryLoads NSAllowsLocalNetworking - NSLocationWhenInUseUsageDescription - + NSCameraUsageDescription + Haptika utilise la caméra arrière et le flash pour mesurer localement un signal pulsatile pendant environ 60 secondes. Aucune image n’est enregistrée. + NSHealthShareUsageDescription + Haptika lit, avec votre accord, votre fréquence cardiaque, votre fréquence au repos et votre SDNN comme contexte historique facultatif. Ces données ne remplacent jamais la mesure caméra. UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities @@ -46,13 +47,6 @@ UIInterfaceOrientationPortrait - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIViewControllerBasedStatusBarAppearance diff --git a/ios/HaptikaApp/PrivacyInfo.xcprivacy b/ios/HaptikaApp/PrivacyInfo.xcprivacy index 41b8317..b9eaa61 100644 --- a/ios/HaptikaApp/PrivacyInfo.xcprivacy +++ b/ios/HaptikaApp/PrivacyInfo.xcprivacy @@ -33,5 +33,7 @@ NSPrivacyTracking + NSPrivacyTrackingDomains + diff --git a/ios/HaptikaWatch/App/HaptikaWatchApp.swift b/ios/HaptikaWatch/App/HaptikaWatchApp.swift new file mode 100644 index 0000000..e4802e3 --- /dev/null +++ b/ios/HaptikaWatch/App/HaptikaWatchApp.swift @@ -0,0 +1,14 @@ +import SwiftUI + +@main +struct HaptikaWatchApp: App { + @StateObject private var model = WatchAppModel() + + var body: some Scene { + WindowGroup { + WatchRootView() + .environmentObject(model) + .task { model.start() } + } + } +} diff --git a/ios/HaptikaWatch/App/WatchAppModel.swift b/ios/HaptikaWatch/App/WatchAppModel.swift new file mode 100644 index 0000000..b1d8be1 --- /dev/null +++ b/ios/HaptikaWatch/App/WatchAppModel.swift @@ -0,0 +1,128 @@ +import Combine +import Foundation +import WatchKit + +@MainActor +public final class WatchAppModel: ObservableObject { + @Published public var hasCompletedOnboarding: Bool { + didSet { defaults.set(hasCompletedOnboarding, forKey: onboardingKey) } + } + @Published public var selectedTab: WatchHomeDestination? + + public let store: WatchStore + public let measurement: WatchHealthMeasurementService + public let haptics: WatchHapticService + public let connectivity: WatchConnectivityService + + private let defaults: UserDefaults + private let onboardingKey = "haptika.watch.onboarding.completed.v1" + private let deviceIDKey = "haptika.watch.device.id.v1" + private var sequence: UInt64 + + public var deviceID: String { + if let value = defaults.string(forKey: deviceIDKey) { return value } + let value = UUID().uuidString + defaults.set(value, forKey: deviceIDKey) + return value + } + + public init( + defaults: UserDefaults = .standard, + store: WatchStore? = nil, + measurement: WatchHealthMeasurementService? = nil, + haptics: WatchHapticService? = nil, + connectivity: WatchConnectivityService? = nil + ) { + self.defaults = defaults + self.store = store ?? WatchStore(defaults: defaults) + self.measurement = measurement ?? WatchHealthMeasurementService() + self.haptics = haptics ?? WatchHapticService() + self.connectivity = connectivity ?? WatchConnectivityService() + hasCompletedOnboarding = defaults.bool(forKey: onboardingKey) + sequence = UInt64(defaults.integer(forKey: "haptika.watch.sequence.v1")) + wireServices() + } + + public func start() { + connectivity.activate() + connectivity.flush(outbox: store.outbox) + } + + public func enqueueMeasurement(_ measurement: WatchMeasurementSummaryV1) { + guard let envelope = makeEnvelope(kind: .measurementUpsert, payload: measurement) else { + store.upsert(measurement) + return + } + store.upsert(measurement, enqueue: envelope) + connectivity.transfer(envelope) + } + + public func enqueueSession(_ session: WatchRegulationSessionV1) { + guard let envelope = makeEnvelope(kind: .regulationSessionUpsert, payload: session) else { + store.upsert(session) + return + } + store.upsert(session, enqueue: envelope) + connectivity.transfer(envelope) + } + + public func deleteMeasurement(_ measurement: WatchMeasurementSummaryV1) { + let tombstone = WatchDeletionTombstoneV1(entityID: measurement.id, entityKind: "measurement") + let envelope = makeEnvelope(kind: .tombstone, payload: tombstone) + _ = store.deleteMeasurement(id: measurement.id, envelope: envelope) + if let envelope { connectivity.transfer(envelope) } + } + + public func saveFeeling(_ feeling: Int, for measurement: WatchMeasurementSummaryV1) { + var updated = measurement + updated.feeling = min(5, max(1, feeling)) + updated.feelingRecordedAt = Date() + updated.updatedAt = Date() + enqueueMeasurement(updated) + } + + private func wireServices() { + measurement.onCompleted = { [weak self] summary in self?.enqueueMeasurement(summary) } + measurement.onLiveHeartRate = { [weak self] sample in + guard let self, + let envelope = self.makeEnvelope( + kind: .liveHeartRate, + payload: WatchLiveHeartRateV1(bpm: sample.bpm, sampledAt: sample.date) + ) else { return } + self.connectivity.sendLive(envelope) + } + connectivity.onAcknowledgement = { [weak self] ids in self?.store.acknowledge(eventIDs: ids) } + connectivity.onEnvelope = { [weak self] envelope in + guard let self else { return } + if envelope.kind == .preferences, + let preferences = try? envelope.decodePayload(WatchPreferencesV1.self) { + self.store.preferences = preferences + } else if envelope.kind == .measurementUpsert, + let measurement = try? envelope.decodePayload(WatchMeasurementSummaryV1.self) { + self.store.upsert(measurement) + } else if envelope.kind == .regulationSessionUpsert, + let session = try? envelope.decodePayload(WatchRegulationSessionV1.self) { + self.store.upsert(session) + } else if envelope.kind == .tombstone, + let tombstone = try? envelope.decodePayload(WatchDeletionTombstoneV1.self) { + self.store.apply(tombstone) + } + } + } + + private func makeEnvelope(kind: WatchSyncKind, payload: Payload) -> WatchSyncEnvelopeV1? { + sequence += 1 + defaults.set(Int(sequence), forKey: "haptika.watch.sequence.v1") + return try? .init(deviceID: deviceID, sequence: sequence, kind: kind, payload: payload) + } +} + +public enum WatchHomeDestination: String, Hashable, Identifiable { + case measure + case coherence + case exercises + case cardiacMirror + case history + + public var id: String { rawValue } +} diff --git a/ios/HaptikaWatch/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/HaptikaWatch/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..68d9643 --- /dev/null +++ b/ios/HaptikaWatch/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.922", + "green" : "0.741", + "red" : "0.314" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HaptikaWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/HaptikaWatch/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..3cbef10 --- /dev/null +++ b/ios/HaptikaWatch/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HaptikaWatch/Assets.xcassets/Contents.json b/ios/HaptikaWatch/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/ios/HaptikaWatch/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/HaptikaWatch/Core/RegulationProtocol.swift b/ios/HaptikaWatch/Core/RegulationProtocol.swift new file mode 100644 index 0000000..f69bac7 --- /dev/null +++ b/ios/HaptikaWatch/Core/RegulationProtocol.swift @@ -0,0 +1,181 @@ +import Foundation + +public enum RegulationFamily: String, Codable, Sendable { + case breathe + case ground + case recenter +} + +public enum RegulationMode: String, Codable, Sendable { + case haptic + case liveHeartRate +} + +public enum RegulationPhaseKind: String, Codable, Sendable { + case inhale + case hold + case exhale + case anchor +} + +public struct RegulationPhase: Codable, Equatable, Sendable, Identifiable { + public var id: String { "\(kind.rawValue)-\(durationSeconds)-\(label)" } + public let kind: RegulationPhaseKind + public let durationSeconds: TimeInterval + public let label: String + + public init(kind: RegulationPhaseKind, durationSeconds: TimeInterval, label: String) { + self.kind = kind + self.durationSeconds = durationSeconds + self.label = label + } +} + +public struct WatchRegulationProtocol: Codable, Equatable, Sendable, Identifiable { + public let id: String + public let version: Int + public let family: RegulationFamily + public let mode: RegulationMode + public let eyebrow: String + public let title: String + public let summary: String + public let durationSeconds: TimeInterval + public let phases: [RegulationPhase] + + public init( + id: String, + version: Int = 1, + family: RegulationFamily, + mode: RegulationMode, + eyebrow: String, + title: String, + summary: String, + durationSeconds: TimeInterval, + phases: [RegulationPhase] + ) { + self.id = id + self.version = version + self.family = family + self.mode = mode + self.eyebrow = eyebrow + self.title = title + self.summary = summary + self.durationSeconds = durationSeconds + self.phases = phases + } +} + +public enum WatchProtocolCatalog { + private struct Manifest: Decodable { + let schemaVersion: Int + let protocols: [ManifestProtocol] + } + + private struct ManifestProtocol: Decodable { + let kind: String + let family: RegulationFamily + let mode: String + let eyebrow: String + let title: String + let description: String + let durationSeconds: TimeInterval + let phases: [RegulationPhase] + + var watchProtocol: WatchRegulationProtocol { + WatchRegulationProtocol( + id: kind, + version: 1, + family: family, + mode: mode == "haptic" ? .haptic : .liveHeartRate, + eyebrow: eyebrow, + title: title, + summary: description, + durationSeconds: durationSeconds, + phases: phases + ) + } + } + + public static let protocols: [WatchRegulationProtocol] = loadBundledManifest() ?? fallbackProtocols + + private static let fallbackProtocols: [WatchRegulationProtocol] = [ + .init(id: "myCoherence", family: .breathe, mode: .liveHeartRate, eyebrow: "BIOFEEDBACK", title: "Ma Cohérence", summary: "Respiration visuelle 5–5 avec tendance cardiaque.", durationSeconds: 180, phases: [ + .init(kind: .inhale, durationSeconds: 5, label: "Inspirez"), + .init(kind: .exhale, durationSeconds: 5, label: "Expirez"), + ]), + .init(id: "coherence55", family: .breathe, mode: .haptic, eyebrow: "ÉQUILIBRE", title: "Cohérence 5–5", summary: "Une respiration régulière pour retrouver un rythme calme.", durationSeconds: 300, phases: [ + .init(kind: .inhale, durationSeconds: 5, label: "Inspirez"), + .init(kind: .exhale, durationSeconds: 5, label: "Expirez lentement"), + ]), + .init(id: "box4444", family: .breathe, mode: .haptic, eyebrow: "STABILITÉ", title: "Respiration carrée", summary: "Quatre temps égaux pour structurer l’attention.", durationSeconds: 240, phases: [ + .init(kind: .inhale, durationSeconds: 4, label: "Inspirez"), + .init(kind: .hold, durationSeconds: 4, label: "Gardez l’air"), + .init(kind: .exhale, durationSeconds: 4, label: "Expirez"), + .init(kind: .hold, durationSeconds: 4, label: "Restez vide"), + ]), + .init(id: "breathing478", family: .breathe, mode: .haptic, eyebrow: "RALENTISSEMENT", title: "Respiration 4–7–8", summary: "Un cycle court avec une expiration longue.", durationSeconds: 76, phases: [ + .init(kind: .inhale, durationSeconds: 4, label: "Inspirez"), + .init(kind: .hold, durationSeconds: 7, label: "Gardez l’air"), + .init(kind: .exhale, durationSeconds: 8, label: "Expirez lentement"), + ]), + .init(id: "relax46", family: .breathe, mode: .haptic, eyebrow: "RELÂCHEMENT", title: "Expiration 4–6", summary: "Une expiration plus longue pour accompagner la détente.", durationSeconds: 180, phases: [ + .init(kind: .inhale, durationSeconds: 4, label: "Inspirez"), + .init(kind: .exhale, durationSeconds: 6, label: "Expirez lentement"), + ]), + .init(id: "sosStress", family: .recenter, mode: .haptic, eyebrow: "RETOUR AU CALME", title: "SOS tension", summary: "Trois impulsions puis un repère toutes les dix secondes.", durationSeconds: 62, phases: [ + .init(kind: .anchor, durationSeconds: 62, label: "Restez avec la sensation"), + ]), + .init(id: "microAnchor", family: .ground, mode: .haptic, eyebrow: "FORMAT COURT", title: "Micro-ancrage", summary: "Un repère tactile discret pour revenir aux sensations.", durationSeconds: 37, phases: [ + .init(kind: .anchor, durationSeconds: 37, label: "Sentez les points de contact"), + ]), + ] + + public static func protocolWithID(_ id: String) -> WatchRegulationProtocol? { + protocols.first { $0.id == id } + } + + private static func loadBundledManifest(bundle: Bundle = .main) -> [WatchRegulationProtocol]? { + guard let url = bundle.url(forResource: "regulation-protocols.v1", withExtension: "json"), + let data = try? Data(contentsOf: url), + let manifest = try? JSONDecoder().decode(Manifest.self, from: data), + manifest.schemaVersion == 1, + manifest.protocols.count == 7 else { + return nil + } + return manifest.protocols.map(\.watchProtocol) + } +} + +public struct RegulationPhasePosition: Equatable, Sendable { + public let phaseIndex: Int + public let phase: RegulationPhase + public let progress: Double + public let cycleIndex: Int +} + +public enum RegulationPhaseEngine { + public static func position( + in regulation: WatchRegulationProtocol, + elapsedSeconds: TimeInterval + ) -> RegulationPhasePosition? { + guard !regulation.phases.isEmpty else { return nil } + let cycleDuration = regulation.phases.reduce(0) { $0 + $1.durationSeconds } + guard cycleDuration > 0 else { return nil } + let clamped = max(0, min(elapsedSeconds, max(0, regulation.durationSeconds - 0.001))) + let cycleElapsed = clamped.truncatingRemainder(dividingBy: cycleDuration) + var cursor: TimeInterval = 0 + for (index, phase) in regulation.phases.enumerated() { + let end = cursor + phase.durationSeconds + if cycleElapsed < end || index == regulation.phases.count - 1 { + return .init( + phaseIndex: index, + phase: phase, + progress: min(1, max(0, (cycleElapsed - cursor) / phase.durationSeconds)), + cycleIndex: Int(clamped / cycleDuration) + ) + } + cursor = end + } + return nil + } +} diff --git a/ios/HaptikaWatch/Core/WatchBaseline.swift b/ios/HaptikaWatch/Core/WatchBaseline.swift new file mode 100644 index 0000000..662d962 --- /dev/null +++ b/ios/HaptikaWatch/Core/WatchBaseline.swift @@ -0,0 +1,72 @@ +import Foundation + +public enum WatchBaselineMaturity: String, Codable, Sendable { + case unavailable + case provisional + case personalized +} + +public enum WatchBaselineComparison: String, Codable, Sendable { + case unavailable + case usual + case slightlyDifferent + case clearlyDifferent +} + +public struct WatchBaselineSnapshot: Codable, Equatable, Sendable { + public let maturity: WatchBaselineMaturity + public let comparableMeasurements: Int + public let medianBPM: Double? + public let medianAbsoluteDeviation: Double? + + public init(measurements: [WatchMeasurementSummaryV1]) { + let values = measurements + .filter { $0.source == .appleWatchOptical && $0.isValid && $0.quality >= 0.65 && $0.context.isBaselineComparable } + .compactMap(\.averageBPM) + .filter(\.isFinite) + + comparableMeasurements = values.count + medianBPM = Self.median(values) + medianAbsoluteDeviation = medianBPM.map { median in + Self.median(values.map { abs($0 - median) }) ?? 0 + } + if values.count >= 10 { + maturity = .personalized + } else if values.count >= 5 { + maturity = .provisional + } else { + maturity = .unavailable + } + } + + public func compare(bpm: Double?) -> WatchBaselineComparison { + guard maturity != .unavailable, let bpm, bpm.isFinite, let medianBPM else { + return .unavailable + } + let scale = max(medianAbsoluteDeviation ?? 0, 2) + let robustDistance = abs(bpm - medianBPM) / scale + if robustDistance < 2 { return .usual } + if robustDistance < 4 { return .slightlyDifferent } + return .clearlyDifferent + } + + private static func median(_ values: [Double]) -> Double? { + guard !values.isEmpty else { return nil } + let sorted = values.sorted() + let middle = sorted.count / 2 + return sorted.count.isMultiple(of: 2) + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle] + } +} + +public extension WatchBaselineComparison { + var frenchLabel: String { + switch self { + case .unavailable: "Baseline en construction" + case .usual: "Habituel" + case .slightlyDifferent: "Légèrement différent" + case .clearlyDifferent: "Clairement différent" + } + } +} diff --git a/ios/HaptikaWatch/Core/WatchMeasurementValidator.swift b/ios/HaptikaWatch/Core/WatchMeasurementValidator.swift new file mode 100644 index 0000000..87e8048 --- /dev/null +++ b/ios/HaptikaWatch/Core/WatchMeasurementValidator.swift @@ -0,0 +1,73 @@ +import Foundation + +public enum WatchMeasurementInvalidReason: String, Codable, CaseIterable, Sendable { + case noValidHeartRate + case insufficientSamples + case insufficientCoverage + case staleData + case interrupted +} + +public struct WatchMeasurementValidation: Equatable, Sendable { + public let validSamples: [WatchHeartRateSampleV1] + public let currentBPM: Double? + public let averageBPM: Double? + public let minimumBPM: Double? + public let maximumBPM: Double? + public let coverageSeconds: TimeInterval + public let maximumGapSeconds: TimeInterval + public let quality: Double + public let invalidReasons: [WatchMeasurementInvalidReason] + + public var isValid: Bool { invalidReasons.isEmpty } +} + +public enum WatchMeasurementValidator { + public static let minimumBPM = 35.0 + public static let maximumBPM = 220.0 + public static let minimumSamples = 8 + public static let minimumCoverageSeconds = 45.0 + public static let maximumSilenceSeconds = 15.0 + public static let targetDurationSeconds = 60.0 + + public static func validate( + samples: [WatchHeartRateSampleV1], + interrupted: Bool = false + ) -> WatchMeasurementValidation { + let filtered = samples + .filter { $0.bpm.isFinite && (minimumBPM ... maximumBPM).contains($0.bpm) } + .sorted { $0.date < $1.date } + + let coverage = max(0, (filtered.last?.date.timeIntervalSince(filtered.first?.date ?? Date())) ?? 0) + let gaps = zip(filtered, filtered.dropFirst()).map { $1.date.timeIntervalSince($0.date) } + let maximumGap = max(0, gaps.max() ?? 0) + let bpms = filtered.map(\.bpm) + let average = bpms.isEmpty ? nil : bpms.reduce(0, +) / Double(bpms.count) + + var reasons: [WatchMeasurementInvalidReason] = [] + if filtered.isEmpty { reasons.append(.noValidHeartRate) } + if filtered.count < minimumSamples { reasons.append(.insufficientSamples) } + if coverage < minimumCoverageSeconds { reasons.append(.insufficientCoverage) } + if maximumGap > maximumSilenceSeconds { reasons.append(.staleData) } + if interrupted { reasons.append(.interrupted) } + + let sampleScore = min(1, Double(filtered.count) / Double(minimumSamples)) + let coverageScore = min(1, coverage / targetDurationSeconds) + let continuityScore = maximumGap <= maximumSilenceSeconds + ? 1 + : max(0, maximumSilenceSeconds / maximumGap) + let quality = min(1, max(0, sampleScore * coverageScore * continuityScore)) + + return WatchMeasurementValidation( + validSamples: filtered, + currentBPM: filtered.last?.bpm, + averageBPM: average, + minimumBPM: bpms.min(), + maximumBPM: bpms.max(), + coverageSeconds: coverage, + maximumGapSeconds: maximumGap, + quality: quality, + invalidReasons: reasons + ) + } +} diff --git a/ios/HaptikaWatch/Core/WatchModels.swift b/ios/HaptikaWatch/Core/WatchModels.swift new file mode 100644 index 0000000..3e7f05f --- /dev/null +++ b/ios/HaptikaWatch/Core/WatchModels.swift @@ -0,0 +1,377 @@ +import Foundation + +public enum MeasurementSource: String, Codable, CaseIterable, Sendable { + case iphoneCameraPPG + case appleWatchOptical +} + +public struct WatchHeartRateSampleV1: Codable, Equatable, Sendable, Identifiable { + public var id: UUID + public var date: Date + public var bpm: Double + + public init(id: UUID = UUID(), date: Date, bpm: Double) { + self.id = id + self.date = date + self.bpm = bpm + } +} + +public struct WatchDeviceIdentityV1: Codable, Equatable, Sendable { + public var id: String + public var name: String + public var model: String? + public var systemVersion: String? + + public init(id: String, name: String, model: String?, systemVersion: String?) { + self.id = id + self.name = name + self.model = model + self.systemVersion = systemVersion + } + + private enum CodingKeys: String, CodingKey { case id, name, model, systemVersion } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + if let model { try container.encode(model, forKey: .model) } else { try container.encodeNil(forKey: .model) } + if let systemVersion { try container.encode(systemVersion, forKey: .systemVersion) } else { try container.encodeNil(forKey: .systemVersion) } + } +} + +public enum WatchMeasurementPosture: String, Codable, CaseIterable, Sendable { + case seated + case standing + case lying + case unknown +} + +public struct WatchMeasurementContextV1: Codable, Equatable, Sendable { + public var posture: WatchMeasurementPosture + public var isAtRest: Bool + public var recentCaffeine: Bool + public var recentMeal: Bool + public var recentExercise: Bool + public var note: String? + + public init( + posture: WatchMeasurementPosture = .unknown, + isAtRest: Bool = true, + recentCaffeine: Bool = false, + recentMeal: Bool = false, + recentExercise: Bool = false, + note: String? = nil + ) { + self.posture = posture + self.isAtRest = isAtRest + self.recentCaffeine = recentCaffeine + self.recentMeal = recentMeal + self.recentExercise = recentExercise + self.note = note + } + + public var isBaselineComparable: Bool { + isAtRest && !recentCaffeine && !recentMeal && !recentExercise + } +} + +public struct WatchMeasurementSummaryV1: Codable, Equatable, Sendable, Identifiable { + public static let schemaVersion = 1 + + public var id: UUID + public var updatedAt: Date + public var startedAt: Date + public var endedAt: Date + public var source: MeasurementSource + public var device: WatchDeviceIdentityV1 + public var currentBPM: Double? + public var averageBPM: Double? + public var minimumBPM: Double? + public var maximumBPM: Double? + public var samples: [WatchHeartRateSampleV1] + public var coverageSeconds: TimeInterval + public var quality: Double + public var isValid: Bool + public var invalidReasons: [WatchMeasurementInvalidReason] + public var context: WatchMeasurementContextV1 + public var feeling: Int? + public var feelingRecordedAt: Date? + + public init( + id: UUID = UUID(), + updatedAt: Date = Date(), + startedAt: Date, + endedAt: Date, + source: MeasurementSource = .appleWatchOptical, + device: WatchDeviceIdentityV1, + currentBPM: Double?, + averageBPM: Double?, + minimumBPM: Double?, + maximumBPM: Double?, + samples: [WatchHeartRateSampleV1], + coverageSeconds: TimeInterval, + quality: Double, + isValid: Bool, + invalidReasons: [WatchMeasurementInvalidReason] = [], + context: WatchMeasurementContextV1 = .init(), + feeling: Int? = nil, + feelingRecordedAt: Date? = nil + ) { + self.id = id + self.updatedAt = updatedAt + self.startedAt = startedAt + self.endedAt = endedAt + self.source = source + self.device = device + self.currentBPM = currentBPM + self.averageBPM = averageBPM + self.minimumBPM = minimumBPM + self.maximumBPM = maximumBPM + self.samples = samples + self.coverageSeconds = coverageSeconds + self.quality = quality + self.isValid = isValid + self.invalidReasons = invalidReasons + self.context = context + self.feeling = feeling + self.feelingRecordedAt = feelingRecordedAt + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion, id, updatedAt, startedAt, endedAt, source, device + case currentBpm, averageBpm, minimumBpm, maximumBpm, samples, coverage, quality + case valid, invalidReason, context, feeling + } + + private struct WireSample: Codable { + let offsetSeconds: TimeInterval + let bpm: Double + } + + private struct WireContext: Codable { + let atRest: Bool + let posture: String? + let recentExercise: Bool? + let recentCaffeine: Bool? + let recentMeal: Bool? + } + + private struct WireFeeling: Codable { + let score: Int + let recordedAt: Date + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.schemaVersion, forKey: .schemaVersion) + try container.encode(id, forKey: .id) + try container.encode(updatedAt, forKey: .updatedAt) + try container.encode(startedAt, forKey: .startedAt) + try container.encode(endedAt, forKey: .endedAt) + try container.encode(source, forKey: .source) + try container.encode(device, forKey: .device) + if let currentBPM { try container.encode(currentBPM, forKey: .currentBpm) } + else { try container.encodeNil(forKey: .currentBpm) } + try container.encodeIfPresent(averageBPM, forKey: .averageBpm) + try container.encodeIfPresent(minimumBPM, forKey: .minimumBpm) + try container.encodeIfPresent(maximumBPM, forKey: .maximumBpm) + try container.encode(samples.map { WireSample(offsetSeconds: max(0, $0.date.timeIntervalSince(startedAt)), bpm: $0.bpm) }, forKey: .samples) + try container.encode(min(1, max(0, coverageSeconds / 60)), forKey: .coverage) + try container.encode(quality, forKey: .quality) + try container.encode(isValid, forKey: .valid) + if let invalidReason = invalidReasons.first?.rawValue { try container.encode(invalidReason, forKey: .invalidReason) } + else { try container.encodeNil(forKey: .invalidReason) } + try container.encode(WireContext( + atRest: context.isAtRest, + posture: context.posture == .unknown ? nil : context.posture.rawValue, + recentExercise: context.recentExercise, + recentCaffeine: context.recentCaffeine, + recentMeal: context.recentMeal + ), forKey: .context) + if let feeling { + try container.encode(WireFeeling(score: feeling, recordedAt: feelingRecordedAt ?? updatedAt), forKey: .feeling) + } else { + try container.encodeNil(forKey: .feeling) + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else { + throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch measurement schema") + } + id = try container.decode(UUID.self, forKey: .id) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + startedAt = try container.decode(Date.self, forKey: .startedAt) + endedAt = try container.decode(Date.self, forKey: .endedAt) + source = try container.decode(MeasurementSource.self, forKey: .source) + device = try container.decode(WatchDeviceIdentityV1.self, forKey: .device) + currentBPM = try container.decodeIfPresent(Double.self, forKey: .currentBpm) + averageBPM = try container.decodeIfPresent(Double.self, forKey: .averageBpm) + minimumBPM = try container.decodeIfPresent(Double.self, forKey: .minimumBpm) + maximumBPM = try container.decodeIfPresent(Double.self, forKey: .maximumBpm) + let wireSamples = try container.decode([WireSample].self, forKey: .samples) + let decodedStartedAt = startedAt + samples = wireSamples.map { .init(date: decodedStartedAt.addingTimeInterval($0.offsetSeconds), bpm: $0.bpm) } + coverageSeconds = try container.decode(Double.self, forKey: .coverage) * 60 + quality = try container.decode(Double.self, forKey: .quality) + isValid = try container.decode(Bool.self, forKey: .valid) + invalidReasons = (try container.decodeIfPresent(String.self, forKey: .invalidReason)) + .flatMap(WatchMeasurementInvalidReason.init(rawValue:)).map { [$0] } ?? [] + let wireContext = try container.decode(WireContext.self, forKey: .context) + context = .init( + posture: wireContext.posture.flatMap(WatchMeasurementPosture.init(rawValue:)) ?? .unknown, + isAtRest: wireContext.atRest, + recentCaffeine: wireContext.recentCaffeine ?? false, + recentMeal: wireContext.recentMeal ?? false, + recentExercise: wireContext.recentExercise ?? false + ) + let wireFeeling = try container.decodeIfPresent(WireFeeling.self, forKey: .feeling) + feeling = wireFeeling?.score + feelingRecordedAt = wireFeeling?.recordedAt + } +} + +public enum WatchRegulationSessionState: String, Codable, Sendable { + case inProgress + case awaitingAfter + case completed + case cancelled +} + +public struct WatchRegulationSessionV1: Codable, Equatable, Sendable, Identifiable { + public static let currentSchemaVersion = 1 + public let schemaVersion = 1 + + public var id: UUID + public var protocolID: String + public var protocolVersion: Int = 1 + public var startedAt: Date + public var endedAt: Date + public var durationSeconds: TimeInterval + public var state: WatchRegulationSessionState + public var beforeMeasurementID: UUID? + public var afterMeasurementID: UUID? + public var feelingBefore: Int? + public var feelingAfter: Int? + public var healthKitWorkoutID: UUID? + public var updatedAt: Date + + public init( + id: UUID = UUID(), + protocolID: String, + protocolVersion: Int = 1, + startedAt: Date, + endedAt: Date, + durationSeconds: TimeInterval, + state: WatchRegulationSessionState, + beforeMeasurementID: UUID? = nil, + afterMeasurementID: UUID? = nil, + feelingBefore: Int? = nil, + feelingAfter: Int? = nil, + healthKitWorkoutID: UUID? = nil, + updatedAt: Date = Date() + ) { + self.id = id + self.protocolID = protocolID + self.protocolVersion = protocolVersion + self.startedAt = startedAt + self.endedAt = endedAt + self.durationSeconds = durationSeconds + self.state = state + self.beforeMeasurementID = beforeMeasurementID + self.afterMeasurementID = afterMeasurementID + self.feelingBefore = feelingBefore + self.feelingAfter = feelingAfter + self.healthKitWorkoutID = healthKitWorkoutID + self.updatedAt = updatedAt + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion, id, protocolID = "protocolId", startedAt, endedAt, durationSeconds, state + case beforeMeasurementID = "beforeMeasurementId", afterMeasurementID = "afterMeasurementId" + case feelingBefore, feelingAfter, healthKitWorkoutID = "workoutUUID", updatedAt + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(schemaVersion, forKey: .schemaVersion) + try container.encode(id, forKey: .id) + try container.encode(protocolID, forKey: .protocolID) + try container.encode(startedAt, forKey: .startedAt) + try container.encode(endedAt, forKey: .endedAt) + try container.encode(durationSeconds, forKey: .durationSeconds) + try container.encode(state, forKey: .state) + try container.encode(updatedAt, forKey: .updatedAt) + if let beforeMeasurementID { try container.encode(beforeMeasurementID, forKey: .beforeMeasurementID) } + else { try container.encodeNil(forKey: .beforeMeasurementID) } + if let afterMeasurementID { try container.encode(afterMeasurementID, forKey: .afterMeasurementID) } + else { try container.encodeNil(forKey: .afterMeasurementID) } + if let feelingBefore { try container.encode(feelingBefore, forKey: .feelingBefore) } + else { try container.encodeNil(forKey: .feelingBefore) } + if let feelingAfter { try container.encode(feelingAfter, forKey: .feelingAfter) } + else { try container.encodeNil(forKey: .feelingAfter) } + if let healthKitWorkoutID { try container.encode(healthKitWorkoutID, forKey: .healthKitWorkoutID) } + else { try container.encodeNil(forKey: .healthKitWorkoutID) } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(Int.self, forKey: .schemaVersion) == 1 else { + throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch session schema") + } + id = try container.decode(UUID.self, forKey: .id) + protocolID = try container.decode(String.self, forKey: .protocolID) + protocolVersion = 1 + startedAt = try container.decode(Date.self, forKey: .startedAt) + endedAt = try container.decode(Date.self, forKey: .endedAt) + durationSeconds = try container.decode(TimeInterval.self, forKey: .durationSeconds) + state = try container.decode(WatchRegulationSessionState.self, forKey: .state) + beforeMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .beforeMeasurementID) + afterMeasurementID = try container.decodeIfPresent(UUID.self, forKey: .afterMeasurementID) + feelingBefore = try container.decodeIfPresent(Int.self, forKey: .feelingBefore) + feelingAfter = try container.decodeIfPresent(Int.self, forKey: .feelingAfter) + healthKitWorkoutID = try container.decodeIfPresent(UUID.self, forKey: .healthKitWorkoutID) + updatedAt = try container.decode(Date.self, forKey: .updatedAt) + } +} + +public struct WatchDeletionTombstoneV1: Codable, Equatable, Sendable, Identifiable { + public let schemaVersion = 1 + public let id: UUID = UUID() + public let entityID: UUID + public let entityKind: String + public let deletedAt: Date + public let updatedAt: Date + + public init(entityID: UUID, entityKind: String, deletedAt: Date = Date(), updatedAt: Date = Date()) { + self.entityID = entityID + self.entityKind = entityKind + self.deletedAt = deletedAt + self.updatedAt = updatedAt + } + + + private enum CodingKeys: String, CodingKey { + case schemaVersion, entityID = "entityId", entityKind, deletedAt, updatedAt + } +} + +public struct WatchPreferencesV1: Codable, Equatable, Sendable { + public var schemaVersion = 1 + public var saveMindAndBodyWorkouts: Bool + public var protocolCatalogVersion: String + public var updatedAt: Date + + public init( + saveMindAndBodyWorkouts: Bool = true, + protocolCatalogVersion: String = "1.0.0", + updatedAt: Date = Date() + ) { + self.saveMindAndBodyWorkouts = saveMindAndBodyWorkouts + self.protocolCatalogVersion = protocolCatalogVersion + self.updatedAt = updatedAt + } +} diff --git a/ios/HaptikaWatch/Core/WatchResourceArbiter.swift b/ios/HaptikaWatch/Core/WatchResourceArbiter.swift new file mode 100644 index 0000000..cbad8ae --- /dev/null +++ b/ios/HaptikaWatch/Core/WatchResourceArbiter.swift @@ -0,0 +1,40 @@ +import Foundation + +public enum WatchExclusiveResource: String, Codable, Sendable { + case heartRateMeasurement + case hapticRegulation +} + +public enum WatchResourceError: LocalizedError, Equatable { + case busy(WatchExclusiveResource) + case releaseMismatch + + public var errorDescription: String? { + switch self { + case let .busy(resource): "Une activité \(resource.rawValue) est déjà en cours." + case .releaseMismatch: "La ressource libérée ne correspond pas à l’activité en cours." + } + } +} + +public actor WatchResourceArbiter { + public static let shared = WatchResourceArbiter() + + public private(set) var current: WatchExclusiveResource? + + public init() {} + + public func acquire(_ resource: WatchExclusiveResource) throws { + if let current { throw WatchResourceError.busy(current) } + current = resource + } + + public func release(_ resource: WatchExclusiveResource) throws { + guard current == resource else { throw WatchResourceError.releaseMismatch } + current = nil + } + + public func forceRelease() { + current = nil + } +} diff --git a/ios/HaptikaWatch/Core/WatchStore.swift b/ios/HaptikaWatch/Core/WatchStore.swift new file mode 100644 index 0000000..7ec45ae --- /dev/null +++ b/ios/HaptikaWatch/Core/WatchStore.swift @@ -0,0 +1,134 @@ +import Combine +import Foundation + +@MainActor +public final class WatchStore: ObservableObject { + @Published public private(set) var measurements: [WatchMeasurementSummaryV1] = [] + @Published public private(set) var sessions: [WatchRegulationSessionV1] = [] + @Published public private(set) var outbox: [WatchSyncEnvelopeV1] = [] + @Published public private(set) var tombstones: [WatchDeletionTombstoneV1] = [] + @Published public var preferences: WatchPreferencesV1 { + didSet { persist() } + } + + public var baseline: WatchBaselineSnapshot { .init(measurements: measurements) } + + private let defaults: UserDefaults + private let storageKey: String + + private struct Snapshot: Codable { + var measurements: [WatchMeasurementSummaryV1] + var sessions: [WatchRegulationSessionV1] + var outbox: [WatchSyncEnvelopeV1] + var tombstones: [WatchDeletionTombstoneV1] + var preferences: WatchPreferencesV1 + } + + public init(defaults: UserDefaults = .standard, storageKey: String = "haptika.watch.store.v1") { + self.defaults = defaults + self.storageKey = storageKey + if let data = defaults.data(forKey: storageKey), + let snapshot = try? WatchCoding.decoder.decode(Snapshot.self, from: data) { + measurements = snapshot.measurements + sessions = snapshot.sessions + outbox = snapshot.outbox + tombstones = snapshot.tombstones + preferences = snapshot.preferences + } else { + preferences = .init() + } + } + + public func upsert(_ measurement: WatchMeasurementSummaryV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) { + if let tombstone = tombstones.first(where: { $0.entityID == measurement.id }), + tombstone.updatedAt >= measurement.updatedAt { return } + measurements.removeAll { $0.id == measurement.id && $0.updatedAt <= measurement.updatedAt } + if !measurements.contains(where: { $0.id == measurement.id }) { + measurements.append(measurement) + } + measurements.sort { $0.startedAt > $1.startedAt } + measurements = Array(measurements.prefix(30)) + if let envelope { enqueue(envelope) } + persist() + } + + public func upsert(_ session: WatchRegulationSessionV1, enqueue envelope: WatchSyncEnvelopeV1? = nil) { + if let tombstone = tombstones.first(where: { $0.entityID == session.id }), + tombstone.updatedAt >= session.updatedAt { return } + sessions.removeAll { $0.id == session.id && $0.updatedAt <= session.updatedAt } + if !sessions.contains(where: { $0.id == session.id }) { + sessions.append(session) + } + sessions.sort { $0.startedAt > $1.startedAt } + sessions = Array(sessions.prefix(20)) + if let envelope { enqueue(envelope) } + persist() + } + + @discardableResult + public func deleteMeasurement(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 { + measurements.removeAll { $0.id == id } + let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "measurement") + tombstones.removeAll { $0.entityID == id } + tombstones.append(tombstone) + if let envelope { enqueue(envelope) } + persist() + return tombstone + } + + @discardableResult + public func deleteSession(id: UUID, envelope: WatchSyncEnvelopeV1? = nil) -> WatchDeletionTombstoneV1 { + sessions.removeAll { $0.id == id } + let tombstone = WatchDeletionTombstoneV1(entityID: id, entityKind: "regulationSession") + tombstones.removeAll { $0.entityID == id } + tombstones.append(tombstone) + if let envelope { enqueue(envelope) } + persist() + return tombstone + } + + public func apply(_ tombstone: WatchDeletionTombstoneV1) { + if tombstone.entityKind == "measurement" { + measurements.removeAll { $0.id == tombstone.entityID } + } else if tombstone.entityKind == "regulationSession" { + sessions.removeAll { $0.id == tombstone.entityID } + } + if let existing = tombstones.first(where: { $0.entityID == tombstone.entityID }), + existing.updatedAt >= tombstone.updatedAt { return } + tombstones.removeAll { $0.entityID == tombstone.entityID } + tombstones.append(tombstone) + persist() + } + + public func enqueue(_ envelope: WatchSyncEnvelopeV1) { + guard !outbox.contains(where: { $0.eventID == envelope.eventID }) else { return } + outbox.append(envelope) + persist() + } + + public func acknowledge(eventIDs: Set) { + outbox.removeAll { eventIDs.contains($0.eventID) } + persist() + } + + public func replaceFromCompanion( + measurements incomingMeasurements: [WatchMeasurementSummaryV1], + sessions incomingSessions: [WatchRegulationSessionV1] + ) { + for measurement in incomingMeasurements { upsert(measurement) } + for session in incomingSessions { upsert(session) } + } + + private func persist() { + let snapshot = Snapshot( + measurements: measurements, + sessions: sessions, + outbox: outbox, + tombstones: tombstones, + preferences: preferences + ) + if let data = try? WatchCoding.encoder.encode(snapshot) { + defaults.set(data, forKey: storageKey) + } + } +} diff --git a/ios/HaptikaWatch/Core/WatchSyncEnvelope.swift b/ios/HaptikaWatch/Core/WatchSyncEnvelope.swift new file mode 100644 index 0000000..8ee8ad7 --- /dev/null +++ b/ios/HaptikaWatch/Core/WatchSyncEnvelope.swift @@ -0,0 +1,169 @@ +import Foundation + +public enum WatchSyncKind: String, Codable, Sendable { + case liveHeartRate + case measurementUpsert + case regulationSessionUpsert + case feelingUpsert + case tombstone + case preferences + case acknowledgement = "ack" +} + +/// The payload is encoded JSON data. Keeping the transport wrapper non-generic lets the +/// offline outbox contain measurements, sessions and tombstones in a single queue. +public struct WatchSyncEnvelopeV1: Codable, Equatable, Sendable, Identifiable { + public static let schemaVersion = 1 + + public var id: UUID { eventID } + public let eventID: UUID + public let deviceID: String + public let sequence: UInt64 + public let sentAt: Date + public let kind: WatchSyncKind + public let payload: Data + + public init( + eventID: UUID = UUID(), + deviceID: String, + sequence: UInt64, + sentAt: Date = Date(), + kind: WatchSyncKind, + payload: Data + ) { + self.eventID = eventID + self.deviceID = deviceID + self.sequence = sequence + self.sentAt = sentAt + self.kind = kind + self.payload = payload + } + + public init( + eventID: UUID = UUID(), + deviceID: String, + sequence: UInt64, + sentAt: Date = Date(), + kind: WatchSyncKind, + payload: Payload, + encoder: JSONEncoder = WatchCoding.encoder + ) throws { + self.init( + eventID: eventID, + deviceID: deviceID, + sequence: sequence, + sentAt: sentAt, + kind: kind, + payload: try encoder.encode(payload) + ) + } + + public func decodePayload( + _ type: Payload.Type, + decoder: JSONDecoder = WatchCoding.decoder + ) throws -> Payload { + try decoder.decode(type, from: payload) + } + + private enum CodingKeys: String, CodingKey { + case schemaVersion, eventID = "eventId", deviceID = "deviceId", sequence, sentAt, kind, payload + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(Self.schemaVersion, forKey: .schemaVersion) + try container.encode(eventID, forKey: .eventID) + try container.encode(deviceID, forKey: .deviceID) + try container.encode(sequence, forKey: .sequence) + try container.encode(sentAt, forKey: .sentAt) + try container.encode(kind, forKey: .kind) + try container.encode(try WatchJSONValue(data: payload), forKey: .payload) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + guard try container.decode(Int.self, forKey: .schemaVersion) == Self.schemaVersion else { + throw DecodingError.dataCorruptedError(forKey: .schemaVersion, in: container, debugDescription: "Unsupported Watch envelope schema") + } + eventID = try container.decode(UUID.self, forKey: .eventID) + deviceID = try container.decode(String.self, forKey: .deviceID) + sequence = try container.decode(UInt64.self, forKey: .sequence) + sentAt = try container.decode(Date.self, forKey: .sentAt) + kind = try container.decode(WatchSyncKind.self, forKey: .kind) + payload = try container.decode(WatchJSONValue.self, forKey: .payload).data() + } +} + +private enum WatchJSONValue: Codable, Equatable { + case object([String: WatchJSONValue]) + case array([WatchJSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + init(data: Data) throws { + self = try WatchCoding.decoder.decode(Self.self, from: data) + } + + func data() throws -> Data { try WatchCoding.encoder.encode(self) } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null } + else if let value = try? container.decode(Bool.self) { self = .bool(value) } + else if let value = try? container.decode(Double.self) { self = .number(value) } + else if let value = try? container.decode(String.self) { self = .string(value) } + else if let value = try? container.decode([WatchJSONValue].self) { self = .array(value) } + else { self = .object(try container.decode([String: WatchJSONValue].self)) } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .object(value): try container.encode(value) + case let .array(value): try container.encode(value) + case let .string(value): try container.encode(value) + case let .number(value): try container.encode(value) + case let .bool(value): try container.encode(value) + case .null: try container.encodeNil() + } + } +} + +public struct WatchAcknowledgementV1: Codable, Equatable, Sendable { + public let eventIDs: [UUID] + public let receivedAt: Date + + public init(eventIDs: [UUID], receivedAt: Date = Date()) { + self.eventIDs = eventIDs + self.receivedAt = receivedAt + } +} + +public struct WatchLiveHeartRateV1: Codable, Equatable, Sendable { + public let bpm: Double + public let sampledAt: Date + public let measurementID: UUID? + + public init(bpm: Double, sampledAt: Date, measurementID: UUID? = nil) { + self.bpm = bpm + self.sampledAt = sampledAt + self.measurementID = measurementID + } +} + +public enum WatchCoding { + public static var encoder: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + } + + public static var decoder: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/ios/HaptikaWatch/HaptikaWatch.entitlements b/ios/HaptikaWatch/HaptikaWatch.entitlements new file mode 100644 index 0000000..e10f430 --- /dev/null +++ b/ios/HaptikaWatch/HaptikaWatch.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.healthkit + + + diff --git a/ios/HaptikaWatch/Info.plist b/ios/HaptikaWatch/Info.plist new file mode 100644 index 0000000..6605c86 --- /dev/null +++ b/ios/HaptikaWatch/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Haptika + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + NSHealthShareUsageDescription + Haptika lit votre fréquence cardiaque pendant une mesure ou une séance guidée afin d’afficher votre évolution en direct. + NSHealthUpdateUsageDescription + Haptika peut enregistrer dans Santé les séances guidées Corps et esprit que vous terminez, si vous activez cette option. + WKApplication + + WKBackgroundModes + + workout-processing + + WKCompanionAppBundleIdentifier + com.sachagaulin.haptika.r537r56465.dev + + diff --git a/ios/HaptikaWatch/Services/WatchConnectivityService.swift b/ios/HaptikaWatch/Services/WatchConnectivityService.swift new file mode 100644 index 0000000..09c62a1 --- /dev/null +++ b/ios/HaptikaWatch/Services/WatchConnectivityService.swift @@ -0,0 +1,129 @@ +import Combine +import Foundation +import WatchConnectivity + +@MainActor +public final class WatchConnectivityService: NSObject, ObservableObject { + @Published public private(set) var activationState: WCSessionActivationState = .notActivated + @Published public private(set) var isReachable = false + @Published public private(set) var lastSynchronizedAt: Date? + @Published public private(set) var lastError: String? + + public var onEnvelope: ((WatchSyncEnvelopeV1) -> Void)? + public var onAcknowledgement: ((Set) -> Void)? + + private let session: WCSession? + private let encoder = WatchCoding.encoder + private let decoder = WatchCoding.decoder + + public override init() { + session = WCSession.isSupported() ? .default : nil + super.init() + } + + public func activate() { + guard let session else { return } + session.delegate = self + session.activate() + activationState = session.activationState + isReachable = session.isReachable + } + + public func sendLive(_ envelope: WatchSyncEnvelopeV1) { + guard let session, session.isReachable else { return } + do { + let data = try encoder.encode(envelope) + session.sendMessageData(data, replyHandler: nil) { [weak self] error in + Task { @MainActor in self?.lastError = error.localizedDescription } + } + } catch { + lastError = error.localizedDescription + } + } + + /// Guaranteed delivery for durable mutations. WatchConnectivity persists these while offline. + public func transfer(_ envelope: WatchSyncEnvelopeV1) { + guard let session else { return } + do { + session.transferUserInfo(["envelope": try encoder.encode(envelope)]) + } catch { + lastError = error.localizedDescription + } + } + + public func flush(outbox: [WatchSyncEnvelopeV1]) { + for envelope in outbox { transfer(envelope) } + } + + public func updateApplicationContext(preferences: WatchPreferencesV1) { + guard let session else { return } + do { + try session.updateApplicationContext(["preferences": try encoder.encode(preferences)]) + } catch { + lastError = error.localizedDescription + } + } + + private func receive(_ data: Data) { + do { + let envelope = try decoder.decode(WatchSyncEnvelopeV1.self, from: data) + lastSynchronizedAt = Date() + if envelope.kind == .acknowledgement, + let acknowledgement = try? envelope.decodePayload(WatchAcknowledgementV1.self) { + onAcknowledgement?(Set(acknowledgement.eventIDs)) + } else { + onEnvelope?(envelope) + } + } catch { + lastError = error.localizedDescription + } + } +} + +extension WatchConnectivityService: WCSessionDelegate { + nonisolated public func session( + _ session: WCSession, + activationDidCompleteWith activationState: WCSessionActivationState, + error: Error? + ) { + Task { @MainActor [weak self] in + self?.activationState = activationState + self?.isReachable = session.isReachable + self?.lastError = error?.localizedDescription + } + } + + nonisolated public func sessionReachabilityDidChange(_ session: WCSession) { + Task { @MainActor [weak self] in self?.isReachable = session.isReachable } + } + + nonisolated public func session(_ session: WCSession, didReceiveMessageData messageData: Data) { + Task { @MainActor [weak self] in self?.receive(messageData) } + } + + nonisolated public func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any] = [:]) { + guard let data = userInfo["envelope"] as? Data else { return } + Task { @MainActor [weak self] in self?.receive(data) } + } + + nonisolated public func session( + _ session: WCSession, + didReceiveApplicationContext applicationContext: [String: Any] + ) { + guard let data = applicationContext["preferences"] as? Data else { return } + Task { @MainActor [weak self] in + guard let self, + let preferences = try? self.decoder.decode(WatchPreferencesV1.self, from: data) else { return } + let payload = try? self.encoder.encode(preferences) + if let payload { + self.onEnvelope?(.init( + deviceID: "iphone", + sequence: 0, + kind: .preferences, + payload: payload + )) + } + self.lastSynchronizedAt = Date() + } + } +} diff --git a/ios/HaptikaWatch/Services/WatchHapticService.swift b/ios/HaptikaWatch/Services/WatchHapticService.swift new file mode 100644 index 0000000..8396919 --- /dev/null +++ b/ios/HaptikaWatch/Services/WatchHapticService.swift @@ -0,0 +1,127 @@ +import Foundation +import WatchKit + +@MainActor +public final class WatchHapticService: ObservableObject { + @Published public private(set) var isRunning = false + @Published public private(set) var elapsedSeconds: TimeInterval = 0 + + private let arbiter: WatchResourceArbiter + private var task: Task? + private var activeRunID: UUID? + + public init(arbiter: WatchResourceArbiter = .shared) { + self.arbiter = arbiter + } + + public func start(protocol regulation: WatchRegulationProtocol) async throws { + task?.cancel() + task = nil + activeRunID = nil + await arbiter.forceRelease() + try await arbiter.acquire(.hapticRegulation) + let runID = UUID() + activeRunID = runID + isRunning = true + elapsedSeconds = 0 + WKInterfaceDevice.current().play(.start) + + task = Task { @MainActor [weak self] in + guard let self else { return } + defer { + Task { @MainActor in await self.finishRun(runID) } + } + + if regulation.id == "sosStress" { + for _ in 0..<3 { + guard !Task.isCancelled else { return } + WKInterfaceDevice.current().play(.click) + try? await Task.sleep(for: .milliseconds(600)) + } + } + + var previousPhaseIndex = -1 + var previousCycleIndex = -1 + let startedAt = Date() + while !Task.isCancelled { + let elapsed = Date().timeIntervalSince(startedAt) + self.elapsedSeconds = min(regulation.durationSeconds, elapsed) + if elapsed >= regulation.durationSeconds { + WKInterfaceDevice.current().play(.success) + return + } + + if regulation.id == "sosStress" { + let second = Int(elapsed.rounded(.down)) + if second > 0 && second.isMultiple(of: 10) { + WKInterfaceDevice.current().play(.click) + try? await Task.sleep(for: .seconds(1)) + continue + } + } else if let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed), + position.phaseIndex != previousPhaseIndex || position.cycleIndex != previousCycleIndex { + if previousPhaseIndex >= 0 { WKInterfaceDevice.current().play(.click) } + previousPhaseIndex = position.phaseIndex + previousCycleIndex = position.cycleIndex + } + try? await Task.sleep(for: .milliseconds(100)) + } + } + } + + public func startCardiacMirror( + initialBPM: Double, + durationSeconds: TimeInterval = 120, + decelerationBPMPerMinute: Double = 1.5 + ) async throws { + task?.cancel() + task = nil + activeRunID = nil + await arbiter.forceRelease() + try await arbiter.acquire(.hapticRegulation) + let runID = UUID() + activeRunID = runID + isRunning = true + elapsedSeconds = 0 + WKInterfaceDevice.current().play(.start) + task = Task { @MainActor [weak self] in + guard let self else { return } + defer { + Task { @MainActor in await self.finishRun(runID) } + } + let startedAt = Date() + while !Task.isCancelled { + let elapsed = Date().timeIntervalSince(startedAt) + self.elapsedSeconds = min(durationSeconds, elapsed) + guard elapsed < durationSeconds else { + WKInterfaceDevice.current().play(.success) + return + } + let targetBPM = max(35, initialBPM - decelerationBPMPerMinute * elapsed / 60) + WKInterfaceDevice.current().play(.click) + try? await Task.sleep(for: .seconds(60 / targetBPM)) + } + } + } + + public func cancel(playStop: Bool = true) { + guard task != nil || isRunning else { return } + task?.cancel() + task = nil + activeRunID = nil + isRunning = false + if playStop { WKInterfaceDevice.current().play(.stop) } + Task { @MainActor [weak self] in + guard let self, self.activeRunID == nil else { return } + await self.arbiter.forceRelease() + } + } + + private func finishRun(_ runID: UUID) async { + guard activeRunID == runID else { return } + activeRunID = nil + task = nil + isRunning = false + try? await arbiter.release(.hapticRegulation) + } +} diff --git a/ios/HaptikaWatch/Services/WatchHealthMeasurementService.swift b/ios/HaptikaWatch/Services/WatchHealthMeasurementService.swift new file mode 100644 index 0000000..f07c038 --- /dev/null +++ b/ios/HaptikaWatch/Services/WatchHealthMeasurementService.swift @@ -0,0 +1,267 @@ +import Combine +import Foundation +import HealthKit +import WatchKit + +public enum WatchCollectionMode: Equatable, Sendable { + case technicalMeasurement + case guidedMindAndBody(saveToHealth: Bool) +} + +public enum WatchMeasurementState: Equatable, Sendable { + case idle + case authorizing + case waitingForFirstSample(secondsRemaining: Int) + case collecting(progress: Double) + case finishing + case completed(WatchMeasurementSummaryV1) + case failed(String) +} + +@MainActor +public final class WatchHealthMeasurementService: NSObject, ObservableObject { + @Published public private(set) var state: WatchMeasurementState = .idle + @Published public private(set) var currentBPM: Double? + @Published public private(set) var samples: [WatchHeartRateSampleV1] = [] + @Published public private(set) var lastWorkoutUUID: UUID? + + public var onLiveHeartRate: ((WatchHeartRateSampleV1) -> Void)? + public var onCompleted: ((WatchMeasurementSummaryV1) -> Void)? + + private let healthStore: HKHealthStore + private let arbiter: WatchResourceArbiter + private var workoutSession: HKWorkoutSession? + private var builder: HKLiveWorkoutBuilder? + private var collectionMode: WatchCollectionMode = .technicalMeasurement + private var startedAt: Date? + private var validCollectionStartedAt: Date? + private var timeoutTask: Task? + private var finishTask: Task? + private var collectionDurationSeconds: TimeInterval = 60 + + public init( + healthStore: HKHealthStore = HKHealthStore(), + arbiter: WatchResourceArbiter = .shared + ) { + self.healthStore = healthStore + self.arbiter = arbiter + super.init() + } + + public func requestAuthorization() async throws { + guard HKHealthStore.isHealthDataAvailable(), + let heartRate = HKObjectType.quantityType(forIdentifier: .heartRate) else { + throw NSError(domain: "HaptikaWatch.Health", code: 1, userInfo: [NSLocalizedDescriptionKey: "Santé n’est pas disponible sur cette Watch."]) + } + state = .authorizing + try await healthStore.requestAuthorization( + toShare: [HKObjectType.workoutType()], + read: [heartRate] + ) + state = .idle + } + + public func start( + mode: WatchCollectionMode = .technicalMeasurement, + collectionDurationSeconds: TimeInterval = 60 + ) async throws { + guard case .idle = state else { return } + try await arbiter.acquire(.heartRateMeasurement) + do { + let configuration = HKWorkoutConfiguration() + configuration.activityType = .mindAndBody + configuration.locationType = .unknown + let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration) + let builder = session.associatedWorkoutBuilder() + builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration) + session.delegate = self + builder.delegate = self + + workoutSession = session + self.builder = builder + collectionMode = mode + self.collectionDurationSeconds = max(60, collectionDurationSeconds) + samples = [] + currentBPM = nil + lastWorkoutUUID = nil + let now = Date() + startedAt = now + validCollectionStartedAt = nil + state = .waitingForFirstSample(secondsRemaining: 30) + session.startActivity(with: now) + try await builder.beginCollection(at: now) + scheduleFirstSampleTimeout() + } catch { + await arbiter.forceRelease() + state = .failed(error.localizedDescription) + throw error + } + } + + public func cancel() { + timeoutTask?.cancel() + finishTask?.cancel() + workoutSession?.end() + let builder = builder + Task { @MainActor [weak self] in + if let builder { + try? await builder.endCollection(at: Date()) + builder.discardWorkout() + } + await self?.arbiter.forceRelease() + self?.resetResources() + self?.state = .idle + } + } + + public func finish(interrupted: Bool = false) { + guard builder != nil else { return } + state = .finishing + timeoutTask?.cancel() + finishTask?.cancel() + workoutSession?.end() + let endDate = Date() + let startDate = startedAt ?? endDate + let validation = WatchMeasurementValidator.validate(samples: samples, interrupted: interrupted) + let summary = WatchMeasurementSummaryV1( + startedAt: startDate, + endedAt: endDate, + device: WatchDeviceIdentityV1( + id: WKInterfaceDevice.current().identifierForVendor?.uuidString ?? "unknown-watch", + name: WKInterfaceDevice.current().name, + model: WKInterfaceDevice.current().model, + systemVersion: WKInterfaceDevice.current().systemVersion + ), + currentBPM: validation.currentBPM, + averageBPM: validation.averageBPM, + minimumBPM: validation.minimumBPM, + maximumBPM: validation.maximumBPM, + samples: validation.validSamples, + coverageSeconds: validation.coverageSeconds, + quality: validation.quality, + isValid: validation.isValid, + invalidReasons: validation.invalidReasons + ) + + let mode = collectionMode + let builder = self.builder + finishTask = Task { @MainActor [weak self] in + guard let self, let builder else { return } + do { + try await builder.endCollection(at: endDate) + switch mode { + case .technicalMeasurement: + builder.discardWorkout() + case let .guidedMindAndBody(saveToHealth): + if saveToHealth { + self.lastWorkoutUUID = try await builder.finishWorkout()?.uuid + } else { + builder.discardWorkout() + } + } + await self.arbiter.forceRelease() + self.resetResources(keepSamples: true) + self.state = .completed(summary) + self.onCompleted?(summary) + } catch { + builder.discardWorkout() + await self.arbiter.forceRelease() + self.resetResources(keepSamples: true) + self.state = .failed(error.localizedDescription) + } + } + } + + public func resetAfterResult() { + guard workoutSession == nil else { return } + state = .idle + samples = [] + currentBPM = nil + lastWorkoutUUID = nil + } + + private func scheduleFirstSampleTimeout() { + timeoutTask?.cancel() + timeoutTask = Task { @MainActor [weak self] in + for remaining in stride(from: 30, through: 1, by: -1) { + guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return } + self.state = .waitingForFirstSample(secondsRemaining: remaining) + try? await Task.sleep(for: .seconds(1)) + } + guard let self, self.validCollectionStartedAt == nil, !Task.isCancelled else { return } + self.finish() + } + } + + private func scheduleCollectionFinish(from firstSampleDate: Date) { + timeoutTask?.cancel() + finishTask?.cancel() + finishTask = Task { @MainActor [weak self] in + while let self, !Task.isCancelled { + let elapsed = Date().timeIntervalSince(firstSampleDate) + self.state = .collecting(progress: min(1, elapsed / self.collectionDurationSeconds)) + if elapsed >= self.collectionDurationSeconds { + self.finish() + return + } + try? await Task.sleep(for: .milliseconds(250)) + } + } + } + + private func acceptHeartRate(_ bpm: Double, at date: Date) { + guard (WatchMeasurementValidator.minimumBPM ... WatchMeasurementValidator.maximumBPM).contains(bpm) else { return } + let sample = WatchHeartRateSampleV1(date: date, bpm: bpm) + samples.append(sample) + currentBPM = bpm + onLiveHeartRate?(sample) + if validCollectionStartedAt == nil { + validCollectionStartedAt = date + scheduleCollectionFinish(from: date) + } + } + + private func resetResources(keepSamples: Bool = false) { + workoutSession = nil + builder = nil + startedAt = nil + validCollectionStartedAt = nil + timeoutTask = nil + finishTask = nil + if !keepSamples { samples = [] } + } +} + +extension WatchHealthMeasurementService: HKWorkoutSessionDelegate { + nonisolated public func workoutSession( + _ workoutSession: HKWorkoutSession, + didChangeTo toState: HKWorkoutSessionState, + from fromState: HKWorkoutSessionState, + date: Date + ) {} + + nonisolated public func workoutSession(_ workoutSession: HKWorkoutSession, didFailWithError error: Error) { + Task { @MainActor [weak self] in + self?.finish(interrupted: true) + } + } +} + +extension WatchHealthMeasurementService: HKLiveWorkoutBuilderDelegate { + nonisolated public func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {} + + nonisolated public func workoutBuilder( + _ workoutBuilder: HKLiveWorkoutBuilder, + didCollectDataOf collectedTypes: Set + ) { + guard let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate), + collectedTypes.contains(heartRateType), + let statistics = workoutBuilder.statistics(for: heartRateType), + let quantity = statistics.mostRecentQuantity() else { return } + let unit = HKUnit.count().unitDivided(by: .minute()) + let bpm = quantity.doubleValue(for: unit) + Task { @MainActor [weak self] in + self?.acceptHeartRate(bpm, at: Date()) + } + } +} diff --git a/ios/HaptikaWatch/Views/WatchBeforeAfterFlowView.swift b/ios/HaptikaWatch/Views/WatchBeforeAfterFlowView.swift new file mode 100644 index 0000000..3b20c8c --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchBeforeAfterFlowView.swift @@ -0,0 +1,178 @@ +import SwiftUI + +struct WatchBeforeAfterFlowView: View { + enum Stage { case intro, before, beforeFeeling, exercise, after, afterFeeling, result } + + @EnvironmentObject private var model: WatchAppModel + let regulation: WatchRegulationProtocol + @State private var stage: Stage = .intro + @State private var before: WatchMeasurementSummaryV1? + @State private var after: WatchMeasurementSummaryV1? + @State private var exerciseStartedAt: Date? + @State private var flowError: String? + @State private var selectedFeeling = 3 + @State private var feelingBefore: Int? + @State private var feelingAfter: Int? + + var body: some View { + ScrollView { + VStack(spacing: 10) { + switch stage { + case .intro: + Image(systemName: "arrow.left.arrow.right.heart.fill").font(.title).foregroundStyle(WatchTheme.accent) + Text("Avant · Exercice · Après").font(.headline).multilineTextAlignment(.center) + Text("Deux mesures identiques encadrent l’exercice pour comparer le BPM et la qualité, sans score de stress.") + .font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) + Button("Mesurer avant") { startMeasurement(stage: .before) }.buttonStyle(.borderedProminent) + case .before, .after: + measurementContent + case .beforeFeeling: + feelingContent(title: "Comment vous sentez-vous avant ?") { + feelingBefore = selectedFeeling + if var measurement = before { + measurement.feeling = selectedFeeling + measurement.feelingRecordedAt = Date() + measurement.updatedAt = Date() + before = measurement + model.enqueueMeasurement(measurement) + } + stage = .exercise + exerciseStartedAt = Date() + Task { + do { try await model.haptics.start(protocol: regulation) } + catch { flowError = error.localizedDescription } + } + } + case .exercise: + if model.haptics.isRunning, let exerciseStartedAt { + BreathOrb(regulation: regulation, startedAt: exerciseStartedAt) + ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds) + Button("Arrêter", role: .cancel) { model.haptics.cancel() } + } else { + Text("Exercice terminé").font(.headline) + Button("Mesurer après") { startMeasurement(stage: .after) }.buttonStyle(.borderedProminent) + } + case .afterFeeling: + feelingContent(title: "Comment vous sentez-vous après ?") { + feelingAfter = selectedFeeling + if var measurement = after { + measurement.feeling = selectedFeeling + measurement.feelingRecordedAt = Date() + measurement.updatedAt = Date() + after = measurement + model.enqueueMeasurement(measurement) + } + saveCompletedSession() + stage = .result + } + case .result: + resultContent + } + if let flowError { Text(flowError).font(.caption2).foregroundStyle(.orange) } + } + .padding(.horizontal, 4) + } + .navigationTitle("Comparaison") + .haptikaScreen() + .onChange(of: model.measurement.state) { _, newState in handleMeasurementState(newState) } + } + + @ViewBuilder + private var measurementContent: some View { + switch model.measurement.state { + case let .waitingForFirstSample(seconds): + ProgressView() + Text("Recherche du signal · \(seconds) s").font(.caption2) + case let .collecting(progress): + Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold()) + ProgressView(value: progress) + Text(stage == .before ? "Mesure avant" : "Mesure après").font(.caption2) + case .finishing: + ProgressView("Analyse…") + default: + ProgressView() + } + Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro } + } + + private var resultContent: some View { + VStack(spacing: 9) { + Text("Votre comparaison").font(.headline) + WatchCard { + VStack(alignment: .leading, spacing: 5) { + metricRow("Avant", before?.averageBPM) + metricRow("Après", after?.averageBPM) + if let beforeBPM = before?.averageBPM, let afterBPM = after?.averageBPM { + Divider() + Text(String(format: "Écart %+.0f BPM", afterBPM - beforeBPM)).foregroundStyle(WatchTheme.accent) + } + Text("Qualité : \(Int(((before?.quality ?? 0) * 100).rounded())) % → \(Int(((after?.quality ?? 0) * 100).rounded())) %") + if let feelingBefore, let feelingAfter { + Text("Ressenti : \(feelingBefore)/5 → \(feelingAfter)/5") + } + } + .font(.caption) + } + Text("Une variation ponctuelle n’est pas un diagnostic.").font(.caption2).foregroundStyle(WatchTheme.secondary) + } + } + + private func metricRow(_ label: String, _ bpm: Double?) -> some View { + HStack { + Text(label) + Spacer() + Text(bpm.map { "\(Int($0.rounded())) BPM" } ?? "—") + } + } + + private func startMeasurement(stage: Stage) { + self.stage = stage + model.measurement.resetAfterResult() + Task { + do { + try await model.measurement.requestAuthorization() + try await model.measurement.start() + } catch { flowError = error.localizedDescription } + } + } + + private func feelingContent(title: String, onConfirm: @escaping () -> Void) -> some View { + VStack(spacing: 10) { + Text(title).font(.headline).multilineTextAlignment(.center) + Picker("Ressenti", selection: $selectedFeeling) { + ForEach(1...5, id: \.self) { score in Text("\(score) / 5").tag(score) } + } + Button("Continuer", action: onConfirm).buttonStyle(.borderedProminent) + } + } + + private func saveCompletedSession() { + let endedAt = Date() + model.enqueueSession(.init( + protocolID: regulation.id, + startedAt: exerciseStartedAt ?? endedAt, + endedAt: endedAt, + durationSeconds: regulation.durationSeconds, + state: .completed, + beforeMeasurementID: before?.id, + afterMeasurementID: after?.id, + feelingBefore: feelingBefore, + feelingAfter: feelingAfter + )) + } + + private func handleMeasurementState(_ state: WatchMeasurementState) { + guard case let .completed(summary) = state else { return } + if stage == .before { + before = summary + model.measurement.resetAfterResult() + selectedFeeling = 3 + stage = .beforeFeeling + } else if stage == .after { + after = summary + model.measurement.resetAfterResult() + selectedFeeling = 3 + stage = .afterFeeling + } + } +} diff --git a/ios/HaptikaWatch/Views/WatchCardiacMirrorView.swift b/ios/HaptikaWatch/Views/WatchCardiacMirrorView.swift new file mode 100644 index 0000000..bcacd67 --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchCardiacMirrorView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +struct WatchCardiacMirrorView: View { + enum Stage { case intro, measuring, ready, mirroring, completed } + + @EnvironmentObject private var model: WatchAppModel + @State private var stage: Stage = .intro + @State private var initialBPM: Double? + @State private var errorMessage: String? + @State private var mirrorStartedAt: Date? + + var body: some View { + ScrollView { + VStack(spacing: 10) { + switch stage { + case .intro: + Image(systemName: "heart.circle.fill").font(.system(size: 40)).foregroundStyle(.pink) + Text("Miroir cardiaque").font(.headline) + Text(model.connectivity.isReachable ? "Mode connecté : la Watch mesure, l’iPhone peut reconstruire le rythme visuel et haptique." : "Mode Estimation Watch : mesure initiale, arrêt du capteur, puis rythme reconstruit et ralenti.") + .font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) + Button("Mesurer le rythme") { startMeasurement() }.buttonStyle(.borderedProminent) + case .measuring: + Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title2.bold()) + measurementProgress + Text("Aucune vibration pendant la mesure").font(.caption2).foregroundStyle(.orange) + Button("Annuler", role: .cancel) { model.measurement.cancel(); stage = .intro } + case .ready: + Text(initialBPM.map { "Repère initial \(Int($0.rounded())) BPM" } ?? "Repère indisponible").font(.headline) + Text("Le rythme suivant est une estimation reconstruite, pas chaque battement biologique.") + .font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) + if model.connectivity.isReachable { + Label("iPhone connecté", systemImage: "iphone.and.arrow.forward").font(.caption).foregroundStyle(WatchTheme.mint) + Button("Terminer") { stage = .completed } + } else { + Button("Lancer le miroir") { startMirror() }.buttonStyle(.borderedProminent) + } + case .mirroring: + estimatedPulse + ProgressView(value: model.haptics.elapsedSeconds, total: 120) + Text("Ralentissement progressif · −1,5 BPM/min").font(.caption2).foregroundStyle(WatchTheme.secondary) + Button("Arrêter", role: .cancel) { model.haptics.cancel(); stage = .completed } + case .completed: + Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint) + Text("Expérience terminée").font(.headline) + Button("Recommencer") { stage = .intro; model.measurement.resetAfterResult() } + } + if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) } + } + .padding(.horizontal, 4) + } + .navigationTitle("Miroir") + .haptikaScreen() + .onChange(of: model.measurement.state) { _, state in + if case let .completed(summary) = state { + initialBPM = summary.averageBPM + stage = .ready + } + } + .onChange(of: model.haptics.isRunning) { wasRunning, isRunning in + if stage == .mirroring, wasRunning, !isRunning { stage = .completed } + } + .onDisappear { + if stage == .measuring { model.measurement.cancel() } + if stage == .mirroring { model.haptics.cancel() } + } + } + + @ViewBuilder + private var measurementProgress: some View { + switch model.measurement.state { + case let .waitingForFirstSample(seconds): + ProgressView(); Text("Signal · \(seconds) s").font(.caption2) + case let .collecting(progress): ProgressView(value: progress) + case .finishing: ProgressView("Analyse…") + default: EmptyView() + } + } + + private var estimatedPulse: some View { + TimelineView(.animation(minimumInterval: 1 / 20)) { context in + let elapsed = mirrorStartedAt.map { context.date.timeIntervalSince($0) } ?? 0 + let bpm = max(35, (initialBPM ?? 60) - 1.5 * elapsed / 60) + let phase = (elapsed * bpm / 60).truncatingRemainder(dividingBy: 1) + let scale = 0.78 + 0.22 * max(0, 1 - phase * 4) + VStack { + Image(systemName: "heart.fill").font(.system(size: 62)).foregroundStyle(.pink).scaleEffect(scale) + Text("≈ \(Int(bpm.rounded())) BPM").font(.headline.monospacedDigit()) + } + } + } + + private func startMeasurement() { + stage = .measuring + model.measurement.resetAfterResult() + Task { + do { + try await model.measurement.requestAuthorization() + try await model.measurement.start() + } catch { errorMessage = error.localizedDescription } + } + } + + private func startMirror() { + guard let initialBPM else { return } + mirrorStartedAt = Date() + stage = .mirroring + Task { + do { try await model.haptics.startCardiacMirror(initialBPM: initialBPM) } + catch { errorMessage = error.localizedDescription; stage = .ready } + } + } +} diff --git a/ios/HaptikaWatch/Views/WatchCoherenceView.swift b/ios/HaptikaWatch/Views/WatchCoherenceView.swift new file mode 100644 index 0000000..0413243 --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchCoherenceView.swift @@ -0,0 +1,138 @@ +import Charts +import SwiftUI + +struct WatchCoherenceView: View { + @EnvironmentObject private var model: WatchAppModel + @State private var selectedDuration = 180 + @State private var startedAt: Date? + @State private var savedCompletion = false + + private let regulation = WatchProtocolCatalog.protocolWithID("myCoherence")! + + var body: some View { + ScrollView { + VStack(spacing: 10) { + switch model.measurement.state { + case .idle, .authorizing: + Text("Respirez 5 s / 5 s").font(.headline) + Text("Le guide reste visuel : aucune vibration ne peut interrompre la mesure cardiaque.") + .font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) + Picker("Durée", selection: $selectedDuration) { + Text("3 min").tag(180) + Text("5 min").tag(300) + } + Button("Démarrer") { start() }.buttonStyle(.borderedProminent) + case let .waitingForFirstSample(seconds): + BreathOrb(regulation: regulation, startedAt: startedAt ?? Date()) + Text("Signal cardiaque… \(seconds) s").font(.caption2) + cancelButton + case let .collecting(progress): + BreathOrb(regulation: regulation, startedAt: startedAt ?? Date()) + Text(model.measurement.currentBPM.map { "\(Int($0.rounded())) BPM" } ?? "— BPM").font(.title3.bold()) + heartRateTrend(samples: Array(model.measurement.samples.suffix(30))) + ProgressView(value: progress) + Text("Couverture \(Int(model.measurement.samples.last?.date.timeIntervalSince(model.measurement.samples.first?.date ?? Date()) ?? 0)) s") + .font(.caption2).foregroundStyle(WatchTheme.secondary) + cancelButton + case .finishing: + ProgressView("Sauvegarde…") + case let .completed(summary): + Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint) + Text("Séance terminée").font(.headline) + Text(summary.averageBPM.map { "Pouls moyen \(Int($0.rounded())) BPM" } ?? "Pouls non disponible") + .font(.caption) + heartRateTrend(samples: summary.samples) + Text("Couverture \(Int(summary.coverageSeconds)) s · tendance \(trendLabel(summary.samples))") + .font(.caption2).foregroundStyle(WatchTheme.secondary) + Text("Aucun score de cohérence ou de PRV n’est inventé.").font(.caption2).foregroundStyle(WatchTheme.secondary) + Button("Terminer") { model.measurement.resetAfterResult() }.buttonStyle(.borderedProminent) + case let .failed(message): + Text("Séance interrompue").font(.headline) + Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary) + Button("Retour") { model.measurement.resetAfterResult() } + } + } + .padding(.horizontal, 4) + } + .navigationTitle("Ma Cohérence") + .haptikaScreen() + .onChange(of: model.measurement.state) { _, newState in + guard case let .completed(summary) = newState, !savedCompletion, let startedAt else { return } + savedCompletion = true + model.enqueueSession(.init( + protocolID: regulation.id, + startedAt: startedAt, + endedAt: summary.endedAt, + durationSeconds: summary.endedAt.timeIntervalSince(startedAt), + state: .completed, + afterMeasurementID: summary.id, + healthKitWorkoutID: model.measurement.lastWorkoutUUID + )) + } + } + + private func start() { + startedAt = Date() + savedCompletion = false + Task { + do { + try await model.measurement.requestAuthorization() + try await model.measurement.start( + mode: .guidedMindAndBody(saveToHealth: model.store.preferences.saveMindAndBodyWorkouts), + collectionDurationSeconds: TimeInterval(selectedDuration) + ) + } catch {} + } + } + + private var cancelButton: some View { + Button("Arrêter", role: .cancel) { model.measurement.cancel() }.buttonStyle(.bordered) + } + + private func heartRateTrend(samples: [WatchHeartRateSampleV1]) -> some View { + Chart(samples) { sample in + LineMark(x: .value("Temps", sample.date), y: .value("BPM", sample.bpm)) + .foregroundStyle(WatchTheme.mint) + .interpolationMethod(.catmullRom) + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + .frame(height: 44) + } + + private func trendLabel(_ samples: [WatchHeartRateSampleV1]) -> String { + guard samples.count >= 4 else { return "en construction" } + let midpoint = samples.count / 2 + let first = samples.prefix(midpoint).map(\.bpm) + let last = samples.suffix(from: midpoint).map(\.bpm) + let delta = (last.reduce(0, +) / Double(last.count)) - (first.reduce(0, +) / Double(first.count)) + if delta < -2 { return "en baisse" } + if delta > 2 { return "en hausse" } + return "stable" + } +} + +struct BreathOrb: View { + let regulation: WatchRegulationProtocol + let startedAt: Date + + var body: some View { + TimelineView(.animation(minimumInterval: 1 / 20)) { context in + let elapsed = context.date.timeIntervalSince(startedAt) + let position = RegulationPhaseEngine.position(in: regulation, elapsedSeconds: elapsed) + let isExpanding = position?.phase.kind == .inhale + let fraction = position?.progress ?? 0 + let scale = isExpanding ? 0.6 + fraction * 0.4 : 1 - fraction * 0.4 + VStack(spacing: 6) { + ZStack { + Circle().fill(WatchTheme.accent.opacity(0.12)).frame(width: 108, height: 108) + Circle() + .fill(RadialGradient(colors: [WatchTheme.mint, WatchTheme.accent.opacity(0.25)], center: .center, startRadius: 2, endRadius: 52)) + .frame(width: 88, height: 88) + .scaleEffect(scale) + } + Text(position?.phase.label ?? "Respirez").font(.headline) + } + } + } +} diff --git a/ios/HaptikaWatch/Views/WatchExercisesView.swift b/ios/HaptikaWatch/Views/WatchExercisesView.swift new file mode 100644 index 0000000..5749876 --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchExercisesView.swift @@ -0,0 +1,102 @@ +import SwiftUI + +struct WatchExerciseListView: View { + private var hapticProtocols: [WatchRegulationProtocol] { + WatchProtocolCatalog.protocols.filter { $0.mode == .haptic } + } + + var body: some View { + List(hapticProtocols) { regulation in + NavigationLink { + WatchExercisePlayerView(regulation: regulation) + } label: { + VStack(alignment: .leading, spacing: 3) { + Text(regulation.title).font(.subheadline.bold()) + Text(formatDuration(regulation.durationSeconds)).font(.caption2).foregroundStyle(WatchTheme.secondary) + } + } + } + .navigationTitle("Exercices") + .haptikaScreen() + } +} + +struct WatchExercisePlayerView: View { + @EnvironmentObject private var model: WatchAppModel + @Environment(\.dismiss) private var dismiss + let regulation: WatchRegulationProtocol + @State private var startedAt: Date? + @State private var errorMessage: String? + @State private var sessionSaved = false + + var body: some View { + ScrollView { + VStack(spacing: 10) { + if model.haptics.isRunning, let startedAt { + BreathOrb(regulation: regulation, startedAt: startedAt) + ProgressView(value: model.haptics.elapsedSeconds, total: regulation.durationSeconds) + Text("\(Int(max(0, regulation.durationSeconds - model.haptics.elapsedSeconds))) s") + .font(.caption.monospacedDigit()).foregroundStyle(WatchTheme.secondary) + Button("Arrêter", role: .cancel) { + saveSession(state: .cancelled) + model.haptics.cancel() + } + } else if startedAt != nil { + Image(systemName: "checkmark.circle.fill").font(.title).foregroundStyle(WatchTheme.mint) + Text("Exercice terminé").font(.headline) + Button("Terminer") { dismiss() }.buttonStyle(.borderedProminent) + .onAppear { saveSession(state: .completed) } + } else { + Text(regulation.eyebrow).font(.caption2.bold()).foregroundStyle(WatchTheme.accent) + Text(regulation.title).font(.headline).multilineTextAlignment(.center) + Text(regulation.summary).font(.caption2).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) + Text("Aucune mesure cardiaque pendant les vibrations.").font(.caption2.bold()).foregroundStyle(.orange) + Button("Commencer") { start() }.buttonStyle(.borderedProminent) + NavigationLink("Parcours avant / après") { + WatchBeforeAfterFlowView(regulation: regulation) + } + .font(.caption) + } + if let errorMessage { Text(errorMessage).font(.caption2).foregroundStyle(.orange) } + } + .padding(.horizontal, 4) + } + .navigationTitle(regulation.title) + .haptikaScreen() + .onDisappear { + if model.haptics.isRunning { + saveSession(state: .cancelled) + model.haptics.cancel() + } + } + } + + private func start() { + let now = Date() + Task { + do { + try await model.haptics.start(protocol: regulation) + startedAt = now + } catch { + errorMessage = error.localizedDescription + } + } + } + + private func saveSession(state: WatchRegulationSessionState) { + guard !sessionSaved, let startedAt else { return } + sessionSaved = true + model.enqueueSession(.init( + protocolID: regulation.id, + startedAt: startedAt, + endedAt: Date(), + durationSeconds: model.haptics.elapsedSeconds, + state: state + )) + } +} + +private func formatDuration(_ duration: TimeInterval) -> String { + let seconds = Int(duration) + return seconds < 60 ? "\(seconds) s" : "\(seconds / 60) min \(seconds % 60 == 0 ? "" : "\(seconds % 60) s")" +} diff --git a/ios/HaptikaWatch/Views/WatchHistoryView.swift b/ios/HaptikaWatch/Views/WatchHistoryView.swift new file mode 100644 index 0000000..5b4dd87 --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchHistoryView.swift @@ -0,0 +1,111 @@ +import Charts +import SwiftUI + +struct WatchHistoryView: View { + @EnvironmentObject private var model: WatchAppModel + + private var recentSevenDays: [WatchMeasurementSummaryV1] { + let cutoff = Date().addingTimeInterval(-7 * 86_400) + return model.store.measurements.filter { $0.startedAt >= cutoff && $0.isValid } + } + + var body: some View { + List { + if !recentSevenDays.isEmpty { + Section("Tendance 7 jours") { + Chart(recentSevenDays) { measurement in + LineMark( + x: .value("Date", measurement.startedAt), + y: .value("BPM", measurement.averageBPM ?? 0) + ) + .foregroundStyle(WatchTheme.accent) + PointMark( + x: .value("Date", measurement.startedAt), + y: .value("BPM", measurement.averageBPM ?? 0) + ) + .foregroundStyle(WatchTheme.mint) + } + .chartYAxis(.hidden) + .frame(height: 90) + } + } + + Section("Mesures") { + if model.store.measurements.isEmpty { + Text("Aucune mesure synchronisée").font(.caption).foregroundStyle(WatchTheme.secondary) + } + ForEach(model.store.measurements) { measurement in + NavigationLink { + WatchMeasurementDetailView(measurement: measurement) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Incomplète").font(.subheadline.bold()) + Text(measurement.startedAt, style: .date).font(.caption2).foregroundStyle(WatchTheme.secondary) + } + Spacer() + Text("\(Int((measurement.quality * 100).rounded())) %").font(.caption2).foregroundStyle(measurement.isValid ? WatchTheme.mint : .orange) + } + } + } + } + + Section("Séances") { + if model.store.sessions.isEmpty { + Text("Aucune séance").font(.caption).foregroundStyle(WatchTheme.secondary) + } + ForEach(model.store.sessions) { session in + VStack(alignment: .leading, spacing: 2) { + Text(WatchProtocolCatalog.protocolWithID(session.protocolID)?.title ?? session.protocolID).font(.subheadline.bold()) + Text(session.startedAt, style: .relative).font(.caption2).foregroundStyle(WatchTheme.secondary) + } + } + } + } + .navigationTitle("Historique") + .haptikaScreen() + } +} + +struct WatchMeasurementDetailView: View { + @EnvironmentObject private var model: WatchAppModel + @Environment(\.dismiss) private var dismiss + let measurement: WatchMeasurementSummaryV1 + @State private var confirmDelete = false + + var body: some View { + List { + Section("Mesure Watch") { + metric("Moyenne", measurement.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "—") + metric("Min / max", "\(Int(measurement.minimumBPM ?? 0)) / \(Int(measurement.maximumBPM ?? 0))") + metric("Qualité", "\(Int((measurement.quality * 100).rounded())) %") + metric("Couverture", "\(Int(measurement.coverageSeconds)) s") + metric("Appareil", measurement.device.name) + if let feeling = measurement.feeling { metric("Ressenti", "\(feeling) / 5") } + } + Section { + Text("Pouls optique Apple Watch. Pas de PRV ni de diagnostic médical.") + .font(.caption2).foregroundStyle(WatchTheme.secondary) + } + Button("Supprimer", role: .destructive) { confirmDelete = true } + } + .navigationTitle(measurement.startedAt.formatted(date: .abbreviated, time: .shortened)) + .haptikaScreen() + .confirmationDialog("Supprimer cette mesure ?", isPresented: $confirmDelete) { + Button("Supprimer", role: .destructive) { + model.deleteMeasurement(measurement) + dismiss() + } + Button("Annuler", role: .cancel) {} + } + } + + private func metric(_ label: String, _ value: String) -> some View { + HStack { + Text(label).foregroundStyle(WatchTheme.secondary) + Spacer() + Text(value).multilineTextAlignment(.trailing) + } + .font(.caption) + } +} diff --git a/ios/HaptikaWatch/Views/WatchMeasurementView.swift b/ios/HaptikaWatch/Views/WatchMeasurementView.swift new file mode 100644 index 0000000..09b231f --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchMeasurementView.swift @@ -0,0 +1,115 @@ +import SwiftUI + +struct WatchMeasurementView: View { + @EnvironmentObject private var model: WatchAppModel + + var body: some View { + ScrollView { + VStack(spacing: 10) { + switch model.measurement.state { + case .idle, .authorizing: + preparation + case let .waitingForFirstSample(secondsRemaining): + liveHeader(bpm: nil, caption: "Recherche du signal · \(secondsRemaining) s") + ProgressView() + cancelButton + case let .collecting(progress): + liveHeader(bpm: model.measurement.currentBPM, caption: "Mesure en cours") + ProgressView(value: progress) + Text("Restez immobile, bras détendu").font(.caption2).foregroundStyle(WatchTheme.secondary) + cancelButton + case .finishing: + ProgressView("Analyse…") + case let .completed(summary): + WatchMeasurementResultView(summary: summary) + case let .failed(message): + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + Text("Mesure interrompue").font(.headline) + Text(message).font(.caption2).foregroundStyle(WatchTheme.secondary) + Button("Recommencer") { model.measurement.resetAfterResult() } + } + } + .padding(.horizontal, 4) + } + .navigationTitle("Mesurer") + .haptikaScreen() + } + + private var preparation: some View { + VStack(spacing: 10) { + Image(systemName: "applewatch.radiowaves.left.and.right").font(.system(size: 38)).foregroundStyle(WatchTheme.mint) + Text("60 secondes au repos").font(.headline) + Text("Serrez le bracelet sans gêne et posez l’avant-bras. Haptika attendra jusqu’à 30 s le premier BPM.") + .font(.caption2) + .foregroundStyle(WatchTheme.secondary) + .multilineTextAlignment(.center) + Button("Commencer") { + Task { + do { + try await model.measurement.requestAuthorization() + try await model.measurement.start() + } catch {} + } + } + .buttonStyle(.borderedProminent) + } + } + + private func liveHeader(bpm: Double?, caption: String) -> some View { + VStack(spacing: 3) { + Image(systemName: "heart.fill").foregroundStyle(.pink).symbolEffect(.pulse, options: .repeating) + Text(bpm.map { "\(Int($0.rounded()))" } ?? "—") + .font(.system(size: 42, weight: .bold, design: .rounded)) + Text(bpm == nil ? caption : "BPM · \(caption)").font(.caption2).foregroundStyle(WatchTheme.secondary) + } + } + + private var cancelButton: some View { + Button("Arrêter", role: .cancel) { model.measurement.cancel() } + .buttonStyle(.bordered) + } +} + +struct WatchMeasurementResultView: View { + @EnvironmentObject private var model: WatchAppModel + let summary: WatchMeasurementSummaryV1 + + private var comparison: WatchBaselineComparison { + model.store.baseline.compare(bpm: summary.averageBPM) + } + + var body: some View { + VStack(spacing: 9) { + Image(systemName: summary.isValid ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.title) + .foregroundStyle(summary.isValid ? WatchTheme.mint : .orange) + Text(summary.averageBPM.map { "\(Int($0.rounded())) BPM" } ?? "Signal incomplet") + .font(.title3.bold()) + WatchCard { + VStack(alignment: .leading, spacing: 5) { + Label("Qualité \(Int((summary.quality * 100).rounded())) %", systemImage: "checkmark.seal") + Text("\(Int(summary.minimumBPM ?? 0))–\(Int(summary.maximumBPM ?? 0)) BPM") + Text("Couverture \(Int(summary.coverageSeconds)) s") + Divider() + Text(comparison.frenchLabel).foregroundStyle(WatchTheme.accent) + } + .font(.caption) + } + Text("Mesure optique de bien-être, non médicale. Aucun indice PRV n’est calculé sur la Watch.") + .font(.caption2) + .foregroundStyle(WatchTheme.secondary) + if summary.isValid { + Text("Ressenti").font(.caption2).foregroundStyle(WatchTheme.secondary) + HStack(spacing: 4) { + ForEach(1...5, id: \.self) { value in + Button("\(value)") { model.saveFeeling(value, for: summary) } + .buttonStyle(.borderless) + .foregroundStyle(summary.feeling == value ? WatchTheme.mint : .white) + } + } + } + Button("Terminer") { model.measurement.resetAfterResult() } + .buttonStyle(.borderedProminent) + } + } +} diff --git a/ios/HaptikaWatch/Views/WatchRootView.swift b/ios/HaptikaWatch/Views/WatchRootView.swift new file mode 100644 index 0000000..9dbb6ce --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchRootView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct WatchRootView: View { + @EnvironmentObject private var model: WatchAppModel + + var body: some View { + Group { + if model.hasCompletedOnboarding { + WatchHomeView() + } else { + WatchOnboardingView() + } + } + .preferredColorScheme(.dark) + } +} + +struct WatchOnboardingView: View { + @EnvironmentObject private var model: WatchAppModel + @State private var page = 0 + @State private var authorizationError: String? + + var body: some View { + VStack(spacing: 8) { + TabView(selection: $page) { + onboardingPage(icon: "hand.raised.fill", title: "Vos données", text: "Les mesures restent sur vos appareils et se synchronisent avec votre iPhone appairé.").tag(0) + onboardingPage(icon: "heart.text.square.fill", title: "Bien-être, pas diagnostic", text: "Haptika n’est pas un dispositif médical. Consultez un professionnel en cas de symptôme.").tag(1) + onboardingPage(icon: "waveform.path.ecg", title: "Mesurer sans vibrer", text: "La Watch mesure le pouls optique. Les vibrations sont toujours arrêtées pendant cette phase.").tag(2) + } + .tabViewStyle(.verticalPage) + + if let authorizationError { + Text(authorizationError).font(.caption2).foregroundStyle(.orange) + } + + Button(page < 2 ? "Continuer" : "Autoriser Santé") { + if page < 2 { + withAnimation { page += 1 } + } else { + Task { + do { + try await model.measurement.requestAuthorization() + model.hasCompletedOnboarding = true + } catch { + authorizationError = error.localizedDescription + } + } + } + } + .buttonStyle(.borderedProminent) + } + .padding(.horizontal, 6) + .background(WatchTheme.background.ignoresSafeArea()) + } + + private func onboardingPage(icon: String, title: String, text: String) -> some View { + VStack(spacing: 10) { + Image(systemName: icon).font(.system(size: 34)).foregroundStyle(WatchTheme.accent) + Text(title).font(.headline).multilineTextAlignment(.center) + Text(text).font(.caption).foregroundStyle(WatchTheme.secondary).multilineTextAlignment(.center) + } + } +} + +struct WatchHomeView: View { + @EnvironmentObject private var model: WatchAppModel + + var body: some View { + NavigationStack { + List { + Section { + NavigationLink(value: WatchHomeDestination.measure) { homeRow("Mesurer", "waveform.path.ecg", WatchTheme.mint) } + NavigationLink(value: WatchHomeDestination.coherence) { homeRow("Ma Cohérence", "circle.dotted", WatchTheme.accent) } + NavigationLink(value: WatchHomeDestination.exercises) { homeRow("Exercices", "wind", .cyan) } + NavigationLink(value: WatchHomeDestination.cardiacMirror) { homeRow("Miroir cardiaque", "heart.circle", .pink) } + NavigationLink(value: WatchHomeDestination.history) { homeRow("Historique", "chart.xyaxis.line", .indigo) } + } header: { + VStack(alignment: .leading, spacing: 2) { + Text("HAPTIKA").font(.caption2).foregroundStyle(WatchTheme.accent) + Text("Prendre un temps").font(.headline).foregroundStyle(.white) + } + .textCase(nil) + } + + Section { + HStack { + Circle().fill(model.connectivity.isReachable ? WatchTheme.mint : .gray).frame(width: 7, height: 7) + Text(model.connectivity.isReachable ? "iPhone joignable" : "Mode autonome") + } + .font(.caption2) + .foregroundStyle(WatchTheme.secondary) + } + } + .navigationDestination(for: WatchHomeDestination.self) { destination in + switch destination { + case .measure: WatchMeasurementView() + case .coherence: WatchCoherenceView() + case .exercises: WatchExerciseListView() + case .cardiacMirror: WatchCardiacMirrorView() + case .history: WatchHistoryView() + } + } + .haptikaScreen() + } + } + + private func homeRow(_ title: String, _ icon: String, _ color: Color) -> some View { + Label { + Text(title).font(.subheadline.weight(.semibold)) + } icon: { + Image(systemName: icon).foregroundStyle(color) + } + } +} diff --git a/ios/HaptikaWatch/Views/WatchTheme.swift b/ios/HaptikaWatch/Views/WatchTheme.swift new file mode 100644 index 0000000..eac4ea8 --- /dev/null +++ b/ios/HaptikaWatch/Views/WatchTheme.swift @@ -0,0 +1,29 @@ +import SwiftUI + +enum WatchTheme { + static let background = Color(red: 0.015, green: 0.04, blue: 0.10) + static let card = Color(red: 0.035, green: 0.12, blue: 0.22) + static let accent = Color(red: 0.37, green: 0.79, blue: 0.96) + static let mint = Color(red: 0.38, green: 0.88, blue: 0.78) + static let secondary = Color(red: 0.66, green: 0.73, blue: 0.84) +} + +struct WatchCard: View { + @ViewBuilder var content: Content + + var body: some View { + content + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(WatchTheme.card.opacity(0.82), in: RoundedRectangle(cornerRadius: 18)) + .overlay(RoundedRectangle(cornerRadius: 18).stroke(WatchTheme.accent.opacity(0.22))) + } +} + +extension View { + func haptikaScreen() -> some View { + scrollContentBackground(.hidden) + .background(WatchTheme.background.ignoresSafeArea()) + .tint(WatchTheme.accent) + } +} diff --git a/ios/HaptikaWatchTests/WatchCoreTests.swift b/ios/HaptikaWatchTests/WatchCoreTests.swift new file mode 100644 index 0000000..a00ad54 --- /dev/null +++ b/ios/HaptikaWatchTests/WatchCoreTests.swift @@ -0,0 +1,126 @@ +import XCTest +@testable import HaptikaWatch + +final class WatchCoreTests: XCTestCase { + private let device = WatchDeviceIdentityV1( + id: "watch-series-10-test", + name: "Apple Watch de test", + model: "Watch7,8", + systemVersion: "11.0" + ) + + func testValidationAcceptsEightSamplesCoveringFortyFiveSeconds() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let samples = (0..<8).map { index in + WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(index) * 7), bpm: 70 + Double(index % 3)) + } + let result = WatchMeasurementValidator.validate(samples: samples) + XCTAssertTrue(result.isValid) + XCTAssertEqual(result.validSamples.count, 8) + XCTAssertEqual(result.coverageSeconds, 49, accuracy: 0.001) + } + + func testValidationRejectsOutOfRangeAndLongSilence() { + let start = Date(timeIntervalSince1970: 1_700_000_000) + let offsets: [TimeInterval] = [0, 5, 10, 15, 20, 25, 45, 50, 55] + var samples = offsets.map { WatchHeartRateSampleV1(date: start.addingTimeInterval($0), bpm: 72) } + samples.append(.init(date: start.addingTimeInterval(56), bpm: 221)) + let result = WatchMeasurementValidator.validate(samples: samples) + XCTAssertFalse(result.isValid) + XCTAssertTrue(result.invalidReasons.contains(.staleData)) + XCTAssertEqual(result.validSamples.count, 9) + } + + func testBaselineMaturesAtFiveAndTenComparableMeasurements() { + let five = (0..<5).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) } + XCTAssertEqual(WatchBaselineSnapshot(measurements: five).maturity, .provisional) + let ten = (0..<10).map { measurement(index: $0, bpm: 70 + Double($0 % 2)) } + XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).maturity, .personalized) + XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 71), .usual) + XCTAssertEqual(WatchBaselineSnapshot(measurements: ten).compare(bpm: 90), .clearlyDifferent) + } + + func testBaselineExcludesLowQualityAndNonRestMeasurements() { + var lowQuality = measurement(index: 0, bpm: 70) + lowQuality.quality = 0.64 + var afterExercise = measurement(index: 1, bpm: 71) + afterExercise.context.recentExercise = true + XCTAssertEqual(WatchBaselineSnapshot(measurements: [lowQuality, afterExercise]).comparableMeasurements, 0) + } + + func testPhaseEngineMovesThroughBoxBreathing() throws { + let protocolValue = try XCTUnwrap(WatchProtocolCatalog.protocolWithID("box4444")) + XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 1)?.phase.kind, .inhale) + XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 5)?.phase.kind, .hold) + XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 9)?.phase.kind, .exhale) + XCTAssertEqual(RegulationPhaseEngine.position(in: protocolValue, elapsedSeconds: 13)?.phase.kind, .hold) + } + + func testEnvelopeEncodesPayloadAsJSONObject() throws { + let summary = measurement(index: 0, bpm: 72) + let envelope = try WatchSyncEnvelopeV1( + eventID: UUID(uuidString: "11111111-1111-4111-8111-111111111111")!, + deviceID: device.id, + sequence: 42, + kind: .measurementUpsert, + payload: summary + ) + let data = try WatchCoding.encoder.encode(envelope) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + XCTAssertEqual(object["schemaVersion"] as? Int, 1) + XCTAssertNotNil(object["payload"] as? [String: Any]) + XCTAssertNil(object["payload"] as? String, "Payload must not be a base64 Data string") + let decoded = try WatchCoding.decoder.decode(WatchSyncEnvelopeV1.self, from: data) + let decodedSummary = try decoded.decodePayload(WatchMeasurementSummaryV1.self) + XCTAssertEqual(decodedSummary.id, summary.id) + } + + func testResourceArbiterExcludesHapticsDuringMeasurement() async throws { + let arbiter = WatchResourceArbiter() + try await arbiter.acquire(.heartRateMeasurement) + do { + try await arbiter.acquire(.hapticRegulation) + XCTFail("Second resource must be rejected") + } catch let error as WatchResourceError { + XCTAssertEqual(error, .busy(.heartRateMeasurement)) + } + try await arbiter.release(.heartRateMeasurement) + try await arbiter.acquire(.hapticRegulation) + } + + @MainActor + func testStoreCapsCanonicalHistoryButKeepsOutbox() throws { + let suite = "HaptikaWatchTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let store = WatchStore(defaults: defaults) + for index in 0..<35 { + let value = measurement(index: index, bpm: 70) + let envelope = try WatchSyncEnvelopeV1(deviceID: device.id, sequence: UInt64(index), kind: .measurementUpsert, payload: value) + store.upsert(value, enqueue: envelope) + } + XCTAssertEqual(store.measurements.count, 30) + XCTAssertEqual(store.outbox.count, 35) + } + + private func measurement(index: Int, bpm: Double) -> WatchMeasurementSummaryV1 { + let start = Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 100)) + let samples = (0..<8).map { sampleIndex in + WatchHeartRateSampleV1(date: start.addingTimeInterval(Double(sampleIndex) * 7), bpm: bpm) + } + return .init( + startedAt: start, + endedAt: start.addingTimeInterval(60), + device: device, + currentBPM: bpm, + averageBPM: bpm, + minimumBPM: bpm, + maximumBPM: bpm, + samples: samples, + coverageSeconds: 49, + quality: 0.82, + isValid: true, + context: .init(posture: .seated) + ) + } +} diff --git a/ios/Podfile b/ios/Podfile index c069db5..8b1c69b 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -5,7 +5,7 @@ require Pod::Executable.execute_command('node', ['-p', {paths: [process.argv[1]]}, )', __dir__]).strip -platform :ios, min_ios_version_supported +platform :ios, '16.0' prepare_react_native_project! linkage = ENV['USE_FRAMEWORKS'] diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..4976377 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,2455 @@ +PODS: + - AsyncStorage (3.1.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - FBLazyVector (0.86.0) + - HaptikaPPG (0.1.0): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - VisionCamera + - Yoga + - hermes-engine (250829098.0.14): + - hermes-engine/Pre-built (= 250829098.0.14) + - hermes-engine/Pre-built (250829098.0.14) + - NitroImage (0.15.1): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - NitroModules (0.36.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RCTDeprecation (0.86.0) + - RCTRequired (0.86.0) + - RCTSwiftUI (0.86.0) + - RCTSwiftUIWrapper (0.86.0): + - RCTSwiftUI + - RCTTypeSafety (0.86.0): + - FBLazyVector (= 0.86.0) + - RCTRequired (= 0.86.0) + - React-Core (= 0.86.0) + - React (0.86.0): + - React-Core (= 0.86.0) + - React-Core/DevSupport (= 0.86.0) + - React-Core/RCTWebSocket (= 0.86.0) + - React-RCTActionSheet (= 0.86.0) + - React-RCTAnimation (= 0.86.0) + - React-RCTBlob (= 0.86.0) + - React-RCTImage (= 0.86.0) + - React-RCTLinking (= 0.86.0) + - React-RCTNetwork (= 0.86.0) + - React-RCTSettings (= 0.86.0) + - React-RCTText (= 0.86.0) + - React-RCTVibration (= 0.86.0) + - React-callinvoker (0.86.0) + - React-Core (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.86.0) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core-prebuilt (0.86.0): + - ReactNativeDependencies + - React-Core/CoreModulesHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/Default (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/DevSupport (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.86.0) + - React-Core/RCTWebSocket (= 0.86.0) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTActionSheetHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTAnimationHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTBlobHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTImageHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTLinkingHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTNetworkHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTSettingsHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTTextHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTVibrationHeaders (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTWebSocket (0.86.0): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.86.0) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-CoreModules (0.86.0): + - RCTTypeSafety (= 0.86.0) + - React-Core-prebuilt + - React-Core/CoreModulesHeaders (= 0.86.0) + - React-debug + - React-featureflags + - React-jsi (= 0.86.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-NativeModulesApple + - React-RCTBlob + - React-RCTFBReactNativeSpec + - React-RCTImage (= 0.86.0) + - React-runtimeexecutor + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-cxxreact (0.86.0): + - hermes-engine + - React-callinvoker (= 0.86.0) + - React-Core-prebuilt + - React-debug (= 0.86.0) + - React-jsi (= 0.86.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-logger (= 0.86.0) + - React-perflogger (= 0.86.0) + - React-runtimeexecutor + - React-timing (= 0.86.0) + - React-utils + - ReactNativeDependencies + - React-debug (0.86.0): + - React-debug/redbox (= 0.86.0) + - React-debug/redbox (0.86.0) + - React-defaultsnativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-domnativemodule + - React-Fabric/animated + - React-featureflags + - React-featureflagsnativemodule + - React-idlecallbacksnativemodule + - React-intersectionobservernativemodule + - React-jsi + - React-jsiexecutor + - React-microtasksnativemodule + - React-mutationobservernativemodule + - React-RCTFBReactNativeSpec + - React-viewtransitionnativemodule + - React-webperformancenativemodule + - ReactNativeDependencies + - Yoga + - React-domnativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-FabricComponents + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animated (= 0.86.0) + - React-Fabric/animationbackend (= 0.86.0) + - React-Fabric/animations (= 0.86.0) + - React-Fabric/attributedstring (= 0.86.0) + - React-Fabric/bridging (= 0.86.0) + - React-Fabric/componentregistry (= 0.86.0) + - React-Fabric/componentregistrynative (= 0.86.0) + - React-Fabric/components (= 0.86.0) + - React-Fabric/consistency (= 0.86.0) + - React-Fabric/core (= 0.86.0) + - React-Fabric/dom (= 0.86.0) + - React-Fabric/imagemanager (= 0.86.0) + - React-Fabric/leakchecker (= 0.86.0) + - React-Fabric/mounting (= 0.86.0) + - React-Fabric/observers (= 0.86.0) + - React-Fabric/scheduler (= 0.86.0) + - React-Fabric/telemetry (= 0.86.0) + - React-Fabric/uimanager (= 0.86.0) + - React-Fabric/viewtransition (= 0.86.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animated (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animationbackend + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animationbackend (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animations (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/attributedstring (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/bridging (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistry (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistrynative (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/components/legacyviewmanagerinterop (= 0.86.0) + - React-Fabric/components/root (= 0.86.0) + - React-Fabric/components/scrollview (= 0.86.0) + - React-Fabric/components/view (= 0.86.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/legacyviewmanagerinterop (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/root (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/scrollview (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/view (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric/consistency (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/core (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/dom (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/imagemanager (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/leakchecker (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/mounting (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-jsinspectortracing + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/observers/events (= 0.86.0) + - React-Fabric/observers/intersection (= 0.86.0) + - React-Fabric/observers/mutation (= 0.86.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/events (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/intersection (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/mutation (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/scheduler (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animationbackend + - React-Fabric/observers/events + - React-Fabric/viewtransition + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-performancecdpmetrics + - React-performancetimeline + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/telemetry (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/uimanager/consistency (= 0.86.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager/consistency (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/viewtransition (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-FabricComponents (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components (= 0.86.0) + - React-FabricComponents/textlayoutmanager (= 0.86.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components/inputaccessory (= 0.86.0) + - React-FabricComponents/components/iostextinput (= 0.86.0) + - React-FabricComponents/components/modal (= 0.86.0) + - React-FabricComponents/components/rncore (= 0.86.0) + - React-FabricComponents/components/safeareaview (= 0.86.0) + - React-FabricComponents/components/scrollview (= 0.86.0) + - React-FabricComponents/components/switch (= 0.86.0) + - React-FabricComponents/components/text (= 0.86.0) + - React-FabricComponents/components/textinput (= 0.86.0) + - React-FabricComponents/components/unimplementedview (= 0.86.0) + - React-FabricComponents/components/virtualview (= 0.86.0) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/inputaccessory (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/iostextinput (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/modal (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/rncore (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/safeareaview (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/scrollview (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/switch (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/text (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/textinput (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/unimplementedview (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/virtualview (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/textlayoutmanager (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricImage (0.86.0): + - hermes-engine + - RCTRequired (= 0.86.0) + - RCTTypeSafety (= 0.86.0) + - React-Core-prebuilt + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsiexecutor (= 0.86.0) + - React-logger + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-featureflags (0.86.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-featureflagsnativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-graphics (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-hermes (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.86.0) + - React-jsi + - React-jsiexecutor (= 0.86.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-oscompat + - React-perflogger (= 0.86.0) + - React-runtimeexecutor + - ReactNativeDependencies + - React-idlecallbacksnativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-ImageManager (0.86.0): + - React-Core-prebuilt + - React-Core/Default + - React-debug + - React-Fabric + - React-graphics + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-intersectionobservernativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-Fabric/bridging + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-jserrorhandler (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - ReactCommon/turbomodule/bridging + - ReactNativeDependencies + - React-jsi (0.86.0): + - hermes-engine + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsiexecutor (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-jserrorhandler + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspector (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-oscompat + - React-perflogger (= 0.86.0) + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsinspectorcdp (0.86.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsinspectornetwork (0.86.0): + - React-Core-prebuilt + - React-jsinspectorcdp + - ReactNativeDependencies + - React-jsinspectortracing (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsinspectornetwork + - React-oscompat + - React-timing + - React-utils + - ReactNativeDependencies + - React-jsitooling (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.86.0) + - React-debug + - React-jsi (= 0.86.0) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-jsitracing (0.86.0): + - React-jsi + - React-logger (0.86.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-Mapbuffer (0.86.0): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-microtasksnativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-mutationobservernativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-Fabric/bridging + - React-Fabric/observers/mutation + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context (5.5.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.5.2) + - react-native-safe-area-context/fabric (= 5.5.2) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/common (5.5.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/fabric (5.5.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-NativeModulesApple (0.86.0): + - hermes-engine + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-runtimeexecutor + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-networking (0.86.0): + - React-Core-prebuilt + - React-jsinspectornetwork + - React-jsinspectortracing + - React-performancetimeline + - React-timing + - ReactNativeDependencies + - React-oscompat (0.86.0) + - React-perflogger (0.86.0): + - React-Core-prebuilt + - ReactNativeDependencies + - React-performancecdpmetrics (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-performancetimeline + - React-runtimeexecutor + - React-timing + - ReactNativeDependencies + - React-performancetimeline (0.86.0): + - React-Core-prebuilt + - React-featureflags + - React-jsinspector + - React-jsinspectortracing + - React-perflogger + - React-timing + - ReactNativeDependencies + - React-RCTActionSheet (0.86.0): + - React-Core/RCTActionSheetHeaders (= 0.86.0) + - React-RCTAnimation (0.86.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders + - React-debug + - React-featureflags + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTAppDelegate (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-CoreModules + - React-debug + - React-defaultsnativemodule + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsitooling + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTNetwork + - React-RCTRuntime + - React-rendererdebug + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-RCTBlob (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-Core/RCTBlobHeaders + - React-Core/RCTWebSocket + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTFabric (0.86.0): + - hermes-engine + - RCTSwiftUIWrapper + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricComponents + - React-FabricImage + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-networking + - React-performancecdpmetrics + - React-performancetimeline + - React-RCTAnimation + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTText + - React-rendererconsistency + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-RCTFBReactNativeSpec (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec/components (= 0.86.0) + - ReactCommon + - ReactNativeDependencies + - React-RCTFBReactNativeSpec/components (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-NativeModulesApple + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTImage (0.86.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTImageHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTLinking (0.86.0): + - React-Core/RCTLinkingHeaders (= 0.86.0) + - React-jsi (= 0.86.0) + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactCommon/turbomodule/core (= 0.86.0) + - React-RCTNetwork (0.86.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-NativeModulesApple + - React-networking + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTRuntime (0.86.0): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-debug + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-utils + - ReactNativeDependencies + - React-RCTSettings (0.86.0): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTText (0.86.0): + - React-Core/RCTTextHeaders (= 0.86.0) + - Yoga + - React-RCTVibration (0.86.0): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-rendererconsistency (0.86.0) + - React-renderercss (0.86.0): + - React-debug + - React-utils + - React-rendererdebug (0.86.0): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-RuntimeApple (0.86.0): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-Core/Default + - React-CoreModules + - React-cxxreact + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-Mapbuffer + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-RuntimeCore (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-performancetimeline + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-runtimeexecutor (0.86.0): + - React-Core-prebuilt + - React-debug + - React-featureflags + - React-jsi (= 0.86.0) + - React-utils + - ReactNativeDependencies + - React-RuntimeHermes (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-jsitracing + - React-RuntimeCore + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-runtimescheduler (0.86.0): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectortracing + - React-performancetimeline + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-timing + - React-utils + - ReactNativeDependencies + - React-timing (0.86.0): + - React-debug + - React-utils (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-debug + - React-jsi (= 0.86.0) + - ReactNativeDependencies + - React-viewtransitionnativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-webperformancenativemodule (0.86.0): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-jsi + - React-jsiexecutor + - React-performancetimeline + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactAppDependencyProvider (0.86.0): + - ReactCodegen + - ReactCodegen (0.86.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricImage + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - React-RCTAppDelegate + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactCommon (0.86.0): + - React-Core-prebuilt + - ReactCommon/turbomodule (= 0.86.0) + - ReactNativeDependencies + - ReactCommon/turbomodule (0.86.0): + - hermes-engine + - React-callinvoker (= 0.86.0) + - React-Core-prebuilt + - React-cxxreact (= 0.86.0) + - React-jsi (= 0.86.0) + - React-logger (= 0.86.0) + - React-perflogger (= 0.86.0) + - ReactCommon/turbomodule/bridging (= 0.86.0) + - ReactCommon/turbomodule/core (= 0.86.0) + - ReactNativeDependencies + - ReactCommon/turbomodule/bridging (0.86.0): + - hermes-engine + - React-callinvoker (= 0.86.0) + - React-Core-prebuilt + - React-cxxreact (= 0.86.0) + - React-jsi (= 0.86.0) + - React-logger (= 0.86.0) + - React-perflogger (= 0.86.0) + - ReactNativeDependencies + - ReactCommon/turbomodule/core (0.86.0): + - hermes-engine + - React-callinvoker (= 0.86.0) + - React-Core-prebuilt + - React-cxxreact (= 0.86.0) + - React-debug (= 0.86.0) + - React-featureflags (= 0.86.0) + - React-jsi (= 0.86.0) + - React-logger (= 0.86.0) + - React-perflogger (= 0.86.0) + - React-utils (= 0.86.0) + - ReactNativeDependencies + - ReactNativeDependencies (0.86.0) + - RNWorklets (0.10.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/apple (= 0.10.2) + - RNWorklets/common (= 0.10.2) + - Yoga + - RNWorklets/apple (0.10.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets/common (0.10.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - VisionCamera (5.1.0): + - hermes-engine + - NitroImage + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - VisionCameraWorklets (5.1.0): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - VisionCamera + - Yoga + - Yoga (0.0.0) + +DEPENDENCIES: + - "AsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) + - HaptikaPPG (from `../node_modules/react-native-haptika-ppg`) + - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - NitroImage (from `../node_modules/react-native-nitro-image`) + - NitroModules (from `../node_modules/react-native-nitro-modules`) + - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../node_modules/react-native/Libraries/Required`) + - RCTSwiftUI (from `../node_modules/react-native/ReactApple/RCTSwiftUI`) + - RCTSwiftUIWrapper (from `../node_modules/react-native/ReactApple/RCTSwiftUIWrapper`) + - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../node_modules/react-native/`) + - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../node_modules/react-native/`) + - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`) + - React-Core/RCTWebSocket (from `../node_modules/react-native/`) + - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-intersectionobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver`) + - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - React-mutationobservernativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) + - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-networking (from `../node_modules/react-native/ReactCommon/react/networking`) + - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) + - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) + - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - React-viewtransitionnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition`) + - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) + - ReactAppDependencyProvider (from `build/generated/ios/ReactAppDependencyProvider`) + - ReactCodegen (from `build/generated/ios/ReactCodegen`) + - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - RNWorklets (from `../node_modules/react-native-worklets`) + - VisionCamera (from `../node_modules/react-native-vision-camera`) + - VisionCameraWorklets (from `../node_modules/react-native-vision-camera-worklets`) + - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) + +EXTERNAL SOURCES: + AsyncStorage: + :path: "../node_modules/@react-native-async-storage/async-storage" + FBLazyVector: + :path: "../node_modules/react-native/Libraries/FBLazyVector" + HaptikaPPG: + :path: "../node_modules/react-native-haptika-ppg" + hermes-engine: + :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-v250829098.0.14 + NitroImage: + :path: "../node_modules/react-native-nitro-image" + NitroModules: + :path: "../node_modules/react-native-nitro-modules" + RCTDeprecation: + :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + RCTRequired: + :path: "../node_modules/react-native/Libraries/Required" + RCTSwiftUI: + :path: "../node_modules/react-native/ReactApple/RCTSwiftUI" + RCTSwiftUIWrapper: + :path: "../node_modules/react-native/ReactApple/RCTSwiftUIWrapper" + RCTTypeSafety: + :path: "../node_modules/react-native/Libraries/TypeSafety" + React: + :path: "../node_modules/react-native/" + React-callinvoker: + :path: "../node_modules/react-native/ReactCommon/callinvoker" + React-Core: + :path: "../node_modules/react-native/" + React-Core-prebuilt: + :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec" + React-CoreModules: + :path: "../node_modules/react-native/React/CoreModules" + React-cxxreact: + :path: "../node_modules/react-native/ReactCommon/cxxreact" + React-debug: + :path: "../node_modules/react-native/ReactCommon/react/debug" + React-defaultsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + React-domnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" + React-Fabric: + :path: "../node_modules/react-native/ReactCommon" + React-FabricComponents: + :path: "../node_modules/react-native/ReactCommon" + React-FabricImage: + :path: "../node_modules/react-native/ReactCommon" + React-featureflags: + :path: "../node_modules/react-native/ReactCommon/react/featureflags" + React-featureflagsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + React-graphics: + :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" + React-hermes: + :path: "../node_modules/react-native/ReactCommon/hermes" + React-idlecallbacksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + React-ImageManager: + :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + React-intersectionobservernativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/intersectionobserver" + React-jserrorhandler: + :path: "../node_modules/react-native/ReactCommon/jserrorhandler" + React-jsi: + :path: "../node_modules/react-native/ReactCommon/jsi" + React-jsiexecutor: + :path: "../node_modules/react-native/ReactCommon/jsiexecutor" + React-jsinspector: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" + React-jsinspectorcdp: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + React-jsinspectornetwork: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" + React-jsinspectortracing: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + React-jsitooling: + :path: "../node_modules/react-native/ReactCommon/jsitooling" + React-jsitracing: + :path: "../node_modules/react-native/ReactCommon/hermes/executor/" + React-logger: + :path: "../node_modules/react-native/ReactCommon/logger" + React-Mapbuffer: + :path: "../node_modules/react-native/ReactCommon" + React-microtasksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + React-mutationobservernativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver" + react-native-safe-area-context: + :path: "../node_modules/react-native-safe-area-context" + React-NativeModulesApple: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + React-networking: + :path: "../node_modules/react-native/ReactCommon/react/networking" + React-oscompat: + :path: "../node_modules/react-native/ReactCommon/oscompat" + React-perflogger: + :path: "../node_modules/react-native/ReactCommon/reactperflogger" + React-performancecdpmetrics: + :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" + React-performancetimeline: + :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" + React-RCTActionSheet: + :path: "../node_modules/react-native/Libraries/ActionSheetIOS" + React-RCTAnimation: + :path: "../node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../node_modules/react-native/Libraries/AppDelegate" + React-RCTBlob: + :path: "../node_modules/react-native/Libraries/Blob" + React-RCTFabric: + :path: "../node_modules/react-native/React" + React-RCTFBReactNativeSpec: + :path: "../node_modules/react-native/React" + React-RCTImage: + :path: "../node_modules/react-native/Libraries/Image" + React-RCTLinking: + :path: "../node_modules/react-native/Libraries/LinkingIOS" + React-RCTNetwork: + :path: "../node_modules/react-native/Libraries/Network" + React-RCTRuntime: + :path: "../node_modules/react-native/React/Runtime" + React-RCTSettings: + :path: "../node_modules/react-native/Libraries/Settings" + React-RCTText: + :path: "../node_modules/react-native/Libraries/Text" + React-RCTVibration: + :path: "../node_modules/react-native/Libraries/Vibration" + React-rendererconsistency: + :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" + React-renderercss: + :path: "../node_modules/react-native/ReactCommon/react/renderer/css" + React-rendererdebug: + :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" + React-RuntimeApple: + :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + React-RuntimeCore: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimeexecutor: + :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" + React-RuntimeHermes: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimescheduler: + :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + React-timing: + :path: "../node_modules/react-native/ReactCommon/react/timing" + React-utils: + :path: "../node_modules/react-native/ReactCommon/react/utils" + React-viewtransitionnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/viewtransition" + React-webperformancenativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" + ReactAppDependencyProvider: + :path: build/generated/ios/ReactAppDependencyProvider + ReactCodegen: + :path: build/generated/ios/ReactCodegen + ReactCommon: + :path: "../node_modules/react-native/ReactCommon" + ReactNativeDependencies: + :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + RNWorklets: + :path: "../node_modules/react-native-worklets" + VisionCamera: + :path: "../node_modules/react-native-vision-camera" + VisionCameraWorklets: + :path: "../node_modules/react-native-vision-camera-worklets" + Yoga: + :path: "../node_modules/react-native/ReactCommon/yoga" + +SPEC CHECKSUMS: + AsyncStorage: 163ab23f6aa37a58b7f2379d0b4ee7ac84d6655c + FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d + HaptikaPPG: 890fe2491d3aad89faadde7ffee05da70351bb25 + hermes-engine: 0ba4bb5e0375e00c0b85d784cc46b5806aa9aafe + NitroImage: 2f592086cbdecbbaa1c10dd97e0cdd43b1bb0817 + NitroModules: b4174dd303728e16ad1afb79f64c1a5c69a3b373 + RCTDeprecation: 2a74a2c57675e64419bd89078efde81f7c1de90b + RCTRequired: 30451112e6fef4e6f31b4e7eee0845156e35e4b0 + RCTSwiftUI: 5aaf0b07e747ba749dc6acc94d8bd41eea4b570f + RCTSwiftUIWrapper: ab2ca548be15d63afa95103afc8685a7c3eab78b + RCTTypeSafety: 3eaed17dbddb0b989208b062ea14c44d412b9780 + React: 2574546f2d017abd14d0c9b48cf2b6a0547c2591 + React-callinvoker: 03cd4b931d1d583d87aae99b8f7b6fe26bf571ee + React-Core: 1c824d9c7dd8aa760b5f1b50d5a54c2a3f598f87 + React-Core-prebuilt: 13924a267683b3d6fa4bde9c80380becf83a9c5c + React-CoreModules: 2f9ed75bca7f6dea2b70e8a1f4a5ca9b6be52d76 + React-cxxreact: 7103d5ba69848c039e11079e74ceede05efe795e + React-debug: 8cc8d99ccc664ef9e873027f7fc3fb0a50496012 + React-defaultsnativemodule: 603c108411d39d7bb5ccb8c270f1f50cb6207e10 + React-domnativemodule: c49a502edc85f515a029c341fe5fba155fa6675c + React-Fabric: acc4915d719db793c5853e5c74b54ea5aa48e865 + React-FabricComponents: aebebc98914a4a8fc962fa7b5b98ebaf250acb68 + React-FabricImage: 42e99e48ff73c8b20cee229e622977d0b97b6247 + React-featureflags: c6d8d8d52acf3a956485726076b73eeff7935340 + React-featureflagsnativemodule: c992de92dcf30dcf09efdec17752abe4128ec55f + React-graphics: 239fd9b0512e539d3563f0825618f4e49795eefd + React-hermes: ae4685ca9fa5f47003bc594d3f146e29284136d1 + React-idlecallbacksnativemodule: 10a5be842ab181953c772a3f28cdf94572833eb0 + React-ImageManager: c36a56c3b13eba92e1d0d30da35d0a1ccf29cd6c + React-intersectionobservernativemodule: 71404bfa47d31e4143cc9db3e26178b5cfcd07e1 + React-jserrorhandler: ca2eeb03c1a77bbfab1b5425b943e3e8d92295f2 + React-jsi: c4b6daf8a31ac54f2db49cd6cce29720fec1f3a8 + React-jsiexecutor: acdc1a217b7ea29bcf6315b770d01e6b1c2fca14 + React-jsinspector: 95d6394efe9fb4a64ed33afc076b32a6384c8514 + React-jsinspectorcdp: 6ce51378a552feaaaac1a7b0d995fa0c49fa4f8c + React-jsinspectornetwork: 0db435c9264f200635fdf294a3b643dd3946d4cf + React-jsinspectortracing: 2e4470e1f301ff597bd65299aa95da4bae6e5c4f + React-jsitooling: 796bc991cdce68e2cc059d0271a28e0d4f8b0891 + React-jsitracing: f3008e7b5e1d9de8d9f2ca1b85824ed86b0cea00 + React-logger: fff73f4ceecad968c97baafdc77dcf84befc38b6 + React-Mapbuffer: ce449ccdf3b80384415b925606be8a4bdcfc65d3 + React-microtasksnativemodule: 2eb3f49d0d8e77b5343455eccd057010b8d38b6b + React-mutationobservernativemodule: f0a0d5ae9b51caf7becbeabf836d716cfedb6bf2 + react-native-safe-area-context: 3836dc43241ba89903508a442029e57f7491539f + React-NativeModulesApple: a092d89b58f635ebfab88048b0eda9fb516819fd + React-networking: 968bbbe73590149feb1e72b2af4f6a68e4796ece + React-oscompat: 0b72a7e926954a0415ccd83e0748b6561fe45367 + React-perflogger: 865984e492514aa6e5279fc3e663132cfa4d5022 + React-performancecdpmetrics: 2efbf9bdb48c8d8446f4dd10e8bf0dc5d711772f + React-performancetimeline: 9d256484bff1513481be9f234baba694dc3b52e2 + React-RCTActionSheet: 902c79deec52f99cc48b1051b59bcbe86787d339 + React-RCTAnimation: 09cf722039ae30ff5d64e2b011ff054ae651c3b6 + React-RCTAppDelegate: a47de6fddb7eb0c028abba83138a5ce283bd7e77 + React-RCTBlob: 38c418d067e0c61818223503063310122e44c588 + React-RCTFabric: 480ca2a105730e1790cfd13291d086cb53725825 + React-RCTFBReactNativeSpec: 63131378510a5191515a4adfc308e65b465106f4 + React-RCTImage: 3ce36f82441b76b715818ee7ee95f6f5b34f9ee1 + React-RCTLinking: 2f7b5ed4983122e5115732d25a4360960eab583c + React-RCTNetwork: dcd3b180f33da86f5dc5e928a816eb5464fa7f16 + React-RCTRuntime: 6ef8e778fbab426b12eb5a9b5f7a0e86313c5a10 + React-RCTSettings: b97727ec8c55c35bd284457a647c938c040b942b + React-RCTText: 4d1b88f6d3e1a43afe46706d956ec6664c87b984 + React-RCTVibration: 415d14d6a1a64bf947fcef6b193915a494431168 + React-rendererconsistency: ef8519bdd9931261c6561bfad6506356b8108387 + React-renderercss: 1aa1bf99fa2ace143eb87d5190fd43609fc1e832 + React-rendererdebug: 40a4fb3dea21a7ac1759ca0eb6c88a924aecb075 + React-RuntimeApple: 84fadbb4fe8ca531e15e29a22af05911f17569c6 + React-RuntimeCore: f4d9af5f16d37e63308cb03b36c89d01e7f68a06 + React-runtimeexecutor: 4d59410f66af529d04c84c8b152ced07dabc471e + React-RuntimeHermes: fd719d8f4d9ce79636fe2e09e94b0ac31b7b263b + React-runtimescheduler: 784033620aa5515e2f45a60369eed4d32f9400b8 + React-timing: a16df9ae98f950396d9ce3abf43cb0cb2f21194c + React-utils: b68ee619aef28d8f9b85532d98db0327f7bdf743 + React-viewtransitionnativemodule: 11fe091101d381451b1542c37b2745900254f096 + React-webperformancenativemodule: b5d249419f1546663845c82f24de4e18a3180997 + ReactAppDependencyProvider: dcdd0e1b9559a6d8d8aea05286f4ed085091978e + ReactCodegen: afe0d436ee089d7bea6418429d8312b1d23a6801 + ReactCommon: d5c1bb4427bf51c443de5926aac332c89ddd9363 + ReactNativeDependencies: fa0a54b3f5319ae0e3b9aff32bfee7a424b88e66 + RNWorklets: 53439575fed8525fb8d1cabcc05a3d4d91e6ffe4 + VisionCamera: 2d364657571d6b6404830269d05b1de244586a40 + VisionCameraWorklets: 8393549fac54f37a4cf6a7b1088688cf9b309b7f + Yoga: fe50ab299e578f397fef753cf309c6703a4db29b + +PODFILE CHECKSUM: 5affd7daedb46dd8434c6b798698432600536854 + +COCOAPODS: 1.16.2 diff --git a/jest.config.js b/jest.config.js index 294be30..7fd40df 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,4 @@ module.exports = { preset: '@react-native/jest-preset', + watchman: false, }; diff --git a/metro.config.js b/metro.config.js index 2a0a21c..a90a73e 100644 --- a/metro.config.js +++ b/metro.config.js @@ -6,6 +6,13 @@ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); * * @type {import('@react-native/metro-config').MetroConfig} */ -const config = {}; +const config = { + // The node crawler is more reliable than Watchman on workspaces stored in + // synchronised/user-document folders, where Watchman can deadlock while + // resolving the project root. + resolver: { + useWatchman: false, + }, +}; module.exports = mergeConfig(getDefaultConfig(__dirname), config); diff --git a/onboarding-design-qa.md b/onboarding-design-qa.md new file mode 100644 index 0000000..7053840 --- /dev/null +++ b/onboarding-design-qa.md @@ -0,0 +1,42 @@ +# Design QA — onboarding Haptika + +## Source visual truth + +- Figma file: `bzR4w4mYdTfM7DojW8n9g7` +- Frames: `109:320`, `109:349`, `109:423`, `109:526`, `109:583`, `109:650`, `109:741` +- Natural Figma viewport: 402 × 874 +- The seven frames and the 337 × 369 silhouette asset were captured through the local Figma Desktop MCP before its access expired. + +## Rendered implementation + +- Device: iPhone 17 Pro Max simulator, iOS 26.5 +- Captured viewport: 368 × 800 optimized from the simulator render +- State: fresh install, full seven-step journey, completion, then relaunch with persisted completion +- Evidence: + - `qa/onboarding/step-1-welcome.jpg` + - `qa/onboarding/step-3-assessment.jpg` + - `qa/onboarding/step-6-experience.jpg` + - `qa/onboarding/step-7-complete.jpg` + +## Fidelity review + +- Fonts and typography: system SF typography, light display headings, compact labels and cyan emphasis match the reference hierarchy. No clipping was observed. +- Spacing and layout rhythm: 20-point side margins, 56-point actions, 14-point radii and the seven-step rhythm match the measured frames. The layout expands proportionally on the 440-point Pro Max canvas. +- Colors and tokens: near-black navy canvas, blue-black cards, thin blue borders, muted gray copy and cyan active states match the Figma palette. +- Image quality: the welcome silhouettes are the original Figma node export, not a recreation. Standard UI symbols use the Phosphor React Native icon library. +- Copy and content: the seven Figma sections are present. “Précision clinique” was intentionally replaced by a non-medical claim. +- Interaction: continue actions, assessments, Apple Health choice, profile text input, year modal, experience choice, completion and persistence were exercised in the simulator. + +## Comparison history + +1. Initial Pro Max render used the fixed 402-point frame width, producing excessive side margins. Fixed by allowing the layout to expand to 440 points while retaining the Figma 20-point gutters. +2. Simulator automation exposed duplicate fast taps skipping steps. Fixed with a 450 ms transition lock and retested from a clean install. +3. All seven screens fit without horizontal overflow or hidden primary actions. No visible P0/P1/P2 issue remains in the captured implementation states. + +## Blocking evidence gap + +The Figma Desktop MCP stopped granting access after the implementation captures were taken and now asks for a paid Dev Mode seat/file permission check. A fresh source PNG could therefore not be saved beside the implementation screenshots, so the required combined side-by-side comparison input cannot be produced in this run. + +Focused comparison was not completed for the same reason. The individual source frames and implementation captures were inspected, but the formal combined-input gate remains unavailable. + +final result: blocked diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a71e7a1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,12681 @@ +{ + "name": "HaptikaApp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "HaptikaApp", + "version": "0.0.1", + "dependencies": { + "@react-native-async-storage/async-storage": "3.1.1", + "phosphor-react-native": "^3.0.6", + "react": "19.2.3", + "react-native": "0.86.0", + "react-native-haptika-ppg": "file:packages/react-native-haptika-ppg", + "react-native-nitro-image": "0.15.1", + "react-native-nitro-modules": "0.36.1", + "react-native-safe-area-context": "5.5.2", + "react-native-svg": "^15.15.5", + "react-native-vision-camera": "5.1.0", + "react-native-vision-camera-worklets": "5.1.0", + "react-native-worklets": "0.10.2" + }, + "devDependencies": { + "@babel/core": "^7.25.2", + "@babel/preset-env": "^7.25.3", + "@babel/runtime": "^7.25.0", + "@react-native-community/cli": "^20.2.0", + "@react-native-community/cli-platform-android": "^20.2.0", + "@react-native-community/cli-platform-ios": "^20.2.0", + "@react-native/babel-preset": "0.86.0", + "@react-native/eslint-config": "0.86.0", + "@react-native/jest-preset": "^0.86.0", + "@react-native/metro-config": "0.86.0", + "@react-native/typescript-config": "0.86.0", + "@types/jest": "^29.5.13", + "@types/react": "^19.2.0", + "@types/react-test-renderer": "^19.1.0", + "eslint": "^8.19.0", + "jest": "^29.6.3", + "nitrogen": "0.36.1", + "prettier": "2.8.8", + "react-test-renderer": "19.2.3", + "typescript": "^5.8.3" + }, + "engines": { + "node": ">= 22.11.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/eslint-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.7.tgz", + "integrity": "sha512-zxt+UJTOMKvUt3yOg+D58MLuz334pHp93qifMFcjIIO+9hN6t+ufw2gi7vDPMpxvfnHRR+3VVXvIjineCcgyXw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", + "eslint-visitor-keys": "^2.1.0", + "semver": "^6.3.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || >=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0", + "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { + "version": "5.1.1-v1", + "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", + "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-scope": "5.1.1" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-3.1.1.tgz", + "integrity": "sha512-z+PnLz1n6ECKhgoHZHkfc+dijXZEyZnNFSajbtE0NEbsJhmX8x9GlOeiMQIKX2E4DUqPSgfIh4FYBv1M49KgPQ==", + "license": "MIT", + "dependencies": { + "idb": "8.0.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/@react-native-community/cli": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.2.0.tgz", + "integrity": "sha512-5RZKkBeUFC4hhrzySzxWmRJwXBTc2PE3L6QUgvnJk3QDxNYMY7aGuI8Gr9eZRS1DbiY10IPWqpJfqSIplzVG6A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-clean": "20.2.0", + "@react-native-community/cli-config": "20.2.0", + "@react-native-community/cli-doctor": "20.2.0", + "@react-native-community/cli-server-api": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "@react-native-community/cli-types": "20.2.0", + "commander": "^9.4.1", + "deepmerge": "^4.3.0", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "fs-extra": "^8.1.0", + "graceful-fs": "^4.1.3", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "semver": "^7.5.2" + }, + "bin": { + "rnc-cli": "build/bin.js" + }, + "engines": { + "node": ">=20.19.4" + } + }, + "node_modules/@react-native-community/cli-clean": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.2.0.tgz", + "integrity": "sha512-krqbhFiwHN8l5wZ2XTcrNq9N+DqDWHxW8RK0bIcrK0O+fMvjYi9e/1Pnx2W0CTotOVcZpHzUbgZ4TQVF231Skw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.2.0.tgz", + "integrity": "sha512-GF5FxgDfOSLPi/bSiE/xvOGELwzV2GLQnDED+7ArbPi01W1Vu+hD/m+J+bwX34Pocpo767eXAVxc2h9WFUTUfQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "cosmiconfig": "^9.0.0", + "deepmerge": "^4.3.0", + "fast-glob": "^3.3.2", + "joi": "^17.2.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config-android": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.2.0.tgz", + "integrity": "sha512-lASofUNBVK0Pq3VEJ1JrCfAW94Rnk38DikauycRcoHl0hWjHIN3XhsN20dEq+CaIJoq37aCg5NSSSpmkET+ShA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "fast-glob": "^3.3.2", + "fast-xml-parser": "^5.3.6", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-config-apple": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.2.0.tgz", + "integrity": "sha512-oohr6BV2riWJ5PYjayi1u52bG3H95MwKPkJCZo9pnOlYTjifZQVkMxZ8VTuk392efh0bijVTYfxH8iJPi97SIQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "fast-glob": "^3.3.2", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-doctor": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.2.0.tgz", + "integrity": "sha512-eZjlwmjPoBXgyD6nV5oDDocL1VH5UW4LxZcMAqyN3rQw5vq3CeJikFpNedKTtSl3P6JizIkInyIHpfrW+9qfJA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config": "20.2.0", + "@react-native-community/cli-platform-android": "20.2.0", + "@react-native-community/cli-platform-apple": "20.2.0", + "@react-native-community/cli-platform-ios": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "command-exists": "^1.2.8", + "deepmerge": "^4.3.0", + "envinfo": "^7.13.0", + "execa": "^5.0.0", + "node-stream-zip": "^1.9.1", + "ora": "^5.4.1", + "picocolors": "^1.1.1", + "semver": "^7.5.2", + "wcwidth": "^1.0.1", + "yaml": "^2.2.1" + } + }, + "node_modules/@react-native-community/cli-doctor/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native-community/cli-platform-android": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.2.0.tgz", + "integrity": "sha512-fRglzcf/Yq5lnIkqdA8uf1GvlG18x3Dm48Yvo+l7KiWgh/SVtfVhfvalrtX5rmx+KeJkDA534bGoPvi5+ruWjw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config-android": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "logkitty": "^0.7.1", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-platform-apple": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.2.0.tgz", + "integrity": "sha512-jkEPLAd8C/ZRu39a3nhxwSXe0iisUiJC808GExnrnTcViLtg3sad2ciQ/HjjvbdsPCS9sT+Jr7xh3MzBsPojsQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-config-apple": "20.2.0", + "@react-native-community/cli-tools": "20.2.0", + "execa": "^5.0.0", + "fast-xml-parser": "^5.3.6", + "picocolors": "^1.1.1" + } + }, + "node_modules/@react-native-community/cli-platform-ios": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.2.0.tgz", + "integrity": "sha512-bCKlBt2HoD7WTl/HX60+4HFhR1lKY64Y9MPWu7yWQwOCEqYRMi6MkRxLimiONj9M2/lNf0z9BPHDzdDe5HM2ag==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-platform-apple": "20.2.0" + } + }, + "node_modules/@react-native-community/cli-server-api": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.2.0.tgz", + "integrity": "sha512-AI1fsl+6LNuOoDcdWsnF3s9ozqminGCUlbFcrgdZIJWyOuFBeQclNydI22VO3vWaO4dHQkfVa3Zo10AncWQvwA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@react-native-community/cli-tools": "20.2.0", + "body-parser": "^2.2.2", + "compression": "^1.7.1", + "connect": "^3.6.5", + "errorhandler": "^1.5.1", + "nocache": "^3.0.1", + "open": "^6.2.0", + "pretty-format": "^29.7.0", + "serve-static": "^1.13.1", + "ws": "^6.2.3" + } + }, + "node_modules/@react-native-community/cli-tools": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.2.0.tgz", + "integrity": "sha512-A5W2nqlTidPzGyXzS57jvHsMpQd8iOGSjePj4H318uMbhIdeIHQ5e59fNbdHahS62ISs+2p9s78sVHMbGVa1bg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@vscode/sudo-prompt": "^9.0.0", + "appdirsjs": "^1.2.4", + "execa": "^5.0.0", + "find-up": "^5.0.0", + "launch-editor": "^2.9.1", + "mime": "^2.4.1", + "ora": "^5.4.1", + "picocolors": "^1.1.1", + "prompts": "^2.4.2", + "semver": "^7.5.2" + } + }, + "node_modules/@react-native-community/cli-tools/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native-community/cli-types": { + "version": "20.2.0", + "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.2.0.tgz", + "integrity": "sha512-K60zY/ly8G9rGJQzZ+bFRyLEckAUzyF8Fu9a2Kw6JipCrOl7SiVRBVqjvOr/XnCRKuOpH8i9y7RVg07wkSGRBQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "joi": "^17.2.1" + } + }, + "node_modules/@react-native-community/cli/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "devOptional": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.0.tgz", + "integrity": "sha512-nIaXbm2jX1OTYp0qbviJ3O6KZivoE8z3BnhUQ2LsqfZSWRoOK/n1qsiAr6oALiNKWnXY3j2KPwtYORnZzp8xew==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.0.tgz", + "integrity": "sha512-qdsABWNW7uTll90l4Vh03gjeyu3WVDi2CyiiyvYGMRDcoYbjbQi6df3BMAm9lQI2yslZ1T14LlDDAsgTwNxplA==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.86.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-preset": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.86.0.tgz", + "integrity": "sha512-bYQcWiPySNvF4dns9Ls9gMmwgq66ohvM9Fwc/Kn8r85t66UNHxch3p1QwPiSorDelFauZwJbgo9+ReibTgvpbA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@react-native/babel-plugin-codegen": "0.86.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "react-refresh": "^0.14.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.0.tgz", + "integrity": "sha512-uTs9DBo3+/lUqinsGZK0FKJRBVClrwMXoZToaDxE1Q2SL2e55vs2GwyZfIKzPl5uJnbu4PfFMIp0/mLXLWUMuA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.0.tgz", + "integrity": "sha512-Jv8p1ebEPfTzs8gmrjsdT2XMXFfeAg45Pman+XPLFGaSeGAZkutRFRyX9Cs9aGTSOyIA9YPJ6vDNb1ayTf1FKQ==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.86.0", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", + "semver": "^7.1.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "0.86.0" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.0.tgz", + "integrity": "sha512-7Mb3nDfyJeys+ELF75Ageu7VKERlnIMoO+aNPoXqTXvz+b41L6l2CqMyLpDHxkBSlenij6gEepPNgaIyWHbJZw==", + "license": "BSD-3-Clause", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.0.tgz", + "integrity": "sha512-Y0zEkZzLz8ou6o/VLml1A31X/rMgc6DRjwxwzPMa94qRTMY070WeBCNTITQo4kKTBAUgbxh07oXPQqp0Tpja8w==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.0.tgz", + "integrity": "sha512-20pTO6yTybmvXvro520H6C7jydIQnLKOl5qFtVEcHSdFrY63r3OGei+Rx9bILgSRmH6jgnfEcijcMx7pwWuQtw==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.86.0", + "@react-native/debugger-shell": "0.86.0", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/eslint-config": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/eslint-config/-/eslint-config-0.86.0.tgz", + "integrity": "sha512-N7dtRMx6eVOScVkZ4+4SCGMWRh+nIbUmdc/yIPWvQdPLHKe5NLHYoyPGZvkwWN0Wj26OJwpXNTp53qE5ZiIjKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/eslint-parser": "^7.25.1", + "@react-native/eslint-plugin": "0.86.0", + "@typescript-eslint/eslint-plugin": "^8.36.0", + "@typescript-eslint/parser": "^8.36.0", + "eslint-config-prettier": "^8.5.0", + "eslint-plugin-eslint-comments": "^3.2.0", + "eslint-plugin-ft-flow": "^2.0.1", + "eslint-plugin-jest": "^29.0.1", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-native": "^5.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0", + "prettier": ">=2" + } + }, + "node_modules/@react-native/eslint-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/eslint-plugin/-/eslint-plugin-0.86.0.tgz", + "integrity": "sha512-v0zZAO9tfB72RdluTGHZcYDe5fIH6hf/bk8jATTCdwNNWcvPEjHdzuA2O6M4OAEmC+UDq/kURb+LY5f2fGGRGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.0.tgz", + "integrity": "sha512-a1RcfaEDqWExCGfCwadIxt4l8FvKYgFqeMf2uzeKyAOnb+vTGNIeCvifFL2MqvgaeYxlER437HbMIajGcuJ1pQ==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/jest-preset": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/jest-preset/-/jest-preset-0.86.0.tgz", + "integrity": "sha512-KA+xpIP3DvJy7PQJ9c6ZdEKkOPChl+Rk/rV2MhQACEAzfhWU84407KZQv4ccyO3B4caD0gPrFjE96a4P993nsQ==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@react-native/js-polyfills": "0.86.0", + "babel-jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "regenerator-runtime": "^0.13.2" + }, + "engines": { + "node": ">= 20.19.4" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.0.tgz", + "integrity": "sha512-zYy/Cjd1VTnZ2iCNaG9bDF9C3l2ntESiPRscjIlI5FKugu6aeTwsDSv1aI8Bc4Kp3vEdoVg+UQhLAhE4svREaQ==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/metro-babel-transformer": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.86.0.tgz", + "integrity": "sha512-SjKej3E5qIahqo/G+rSOrmJUQM44RyKtWtO+VfmKAAMoJWkBFomM22hTLKCIS5cdbIAJ9COAmU+KAi2wVSO0wQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@react-native/babel-preset": "0.86.0", + "hermes-parser": "0.36.0", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/metro-config": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/metro-config/-/metro-config-0.86.0.tgz", + "integrity": "sha512-7v+xbTeEci9ZcQ/Z1OqI4RXcqN69wSMDYL5BAMvOReZ7U04+aDQ0/SQhClYPn6x2/RxM4WzMKSAuNyLKqvYVtw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/js-polyfills": "0.86.0", + "@react-native/metro-babel-transformer": "0.86.0", + "metro-config": "^0.84.3", + "metro-runtime": "^0.84.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.0.tgz", + "integrity": "sha512-kG0wfCGghUKlfxkJyyHCDVutWVYWK7/DG58ojA/4v9EfulgF+osuSQmlbNb3rcKX58qutm7JcldSeVLgGFha9g==", + "license": "MIT" + }, + "node_modules/@react-native/typescript-config": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/typescript-config/-/typescript-config-0.86.0.tgz", + "integrity": "sha512-oSuFIEMVAVEXdIvDnrdXsWmIF4fYdgtvAEr2ofn8OY534m7XWm9QAqHHpGUzoK0iwDPlPZ5n2Rb25gqF1SZ0rg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.0.tgz", + "integrity": "sha512-4/ZLXdf/OSpPDVO0AsQ1SJdRIzt5t9BNQ46QwGgxvX7/cirYR5k8KXctNGGgW8lQo2gZChEfY2zFCZg9nM/jiw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "0.86.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@ts-morph/common": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.29.0.tgz", + "integrity": "sha512-35oUmphHbJvQ/+UTwFNme/t2p3FoKiGJ5auTjjpNTop2dyREspirjMy82PLSC1pnDJ8ah1GU98hwpVt64YXQsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^10.0.1", + "path-browserify": "^1.0.1", + "tinyglobby": "^0.2.14" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-test-renderer": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-19.1.0.tgz", + "integrity": "sha512-XD0WZrHqjNrxA/MaR9O22w/RNidWR9YZmBdRGI7wcnWGrv/3dA8wKCJ8m63Sn+tLJhcjmuhOi629N66W6kgWzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz", + "integrity": "sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/type-utils": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.63.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.63.0.tgz", + "integrity": "sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.63.0.tgz", + "integrity": "sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.63.0", + "@typescript-eslint/types": "^8.63.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.63.0.tgz", + "integrity": "sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.63.0.tgz", + "integrity": "sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.63.0.tgz", + "integrity": "sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0", + "@typescript-eslint/utils": "8.63.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.63.0.tgz", + "integrity": "sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.63.0.tgz", + "integrity": "sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.63.0", + "@typescript-eslint/tsconfig-utils": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/visitor-keys": "8.63.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.63.0.tgz", + "integrity": "sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.63.0", + "@typescript-eslint/types": "8.63.0", + "@typescript-eslint/typescript-estree": "8.63.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.63.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.63.0.tgz", + "integrity": "sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.63.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vscode/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-fragments": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz", + "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "colorette": "^1.0.7", + "slice-ansi": "^2.0.0", + "strip-ansi": "^5.0.0" + } + }, + "node_modules/ansi-fragments/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-fragments/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/appdirsjs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz", + "integrity": "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/astral-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", + "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-limiter": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", + "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", + "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.36.0" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", + "integrity": "sha512-Cu2E6QejHWzuDMTkuwgpABFgDfZrXLQq5V13YOACZx4mFAG4IwGTbTfHPMr4WtxlHoXSM8FIuRwYYCz5XiabaQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001800", + "electron-to-chromium": "^1.5.387", + "node-releases": "^2.0.50", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chrome-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "node_modules/chromium-edge-launcher/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/command-exists": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", + "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cosmiconfig": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.389", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", + "integrity": "sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==", + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "devOptional": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/errorhandler": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", + "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "escape-html": "~1.0.3" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-abstract-get": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz", + "integrity": "sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.10.2.tgz", + "integrity": "sha512-/IGJ6+Dka158JnP5n5YFMOszjDWrXggGz1LaK/guZq9vZTmniaKlHcsscvkAhn9y4U+BU3JuUdYvtAMcv30y4A==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-eslint-comments": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz", + "integrity": "sha512-0jkOl0hfojIHHmEHgmNdqv4fmh7300NdpA9FFpF7zaoLvB/QeXOGNLIo86oAveJFrfB1p05kC8hpEMHM8DwWVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5", + "ignore": "^5.0.5" + }, + "engines": { + "node": ">=6.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" + } + }, + "node_modules/eslint-plugin-eslint-comments/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/eslint-plugin-eslint-comments/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-ft-flow": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-ft-flow/-/eslint-plugin-ft-flow-2.0.3.tgz", + "integrity": "sha512-Vbsd/b+LYA99jUbsL6viEUWShFaYQt2YQs3QN3f+aeszOhh2sgdcU0mjzDyD4yyBvMc8qy2uwvBBWfMzEX06tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "string-natural-compare": "^3.0.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "@babel/eslint-parser": "^7.12.0", + "eslint": "^8.1.0" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "29.15.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.15.4.tgz", + "integrity": "sha512-6ln5i9Nkrb27X4w91ZPt/xHDsVQnvxTS2ntgq6r32u+8gymdUrp88TdcBXSveZW0Dl+M5v2H6K75kJhMvUGhjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <7.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/eslint-plugin-react-native": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-native/-/eslint-plugin-react-native-5.0.0.tgz", + "integrity": "sha512-VyWlyCC/7FC/aONibOwLkzmyKg4j9oI8fzrk9WYNs4I8/m436JuOTAFwLvEn1CVvc7La4cPfbCyspP4OYpP52Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-react-native-globals": "^0.1.1" + }, + "peerDependencies": { + "eslint": "^3.17.0 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react-native-globals": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-native-globals/-/eslint-plugin-react-native-globals-0.1.2.tgz", + "integrity": "sha512-9aEPf1JEpiTjcFAmmyw8eiIXmcNZOqaZyHO77wgm0/dWfT/oxC1SrIq8ET38pMxHYrcB6Uew+TzUVsBeczF88g==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-scope/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", + "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "devOptional": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.1.tgz", + "integrity": "sha512-tPb5TTWfgfVx5BNSi2xV0eLr89POeXXn0dXIsCJ9m1narrWxeIyx6je9d7Rce/3NyXLbvuQmLkxq+RuxMWejvw==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.9.3.tgz", + "integrity": "sha512-brCNCeScma/kqa54J4PIDriSSSLssRkuYaUCpvHJulGc3HGI/xxKUCTDcYkAdqJsyb//ydpbxecjC3hB9+tb/g==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.2.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^1.0.1", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.4.1", + "xml-naming": "^0.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/finalhandler/node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "devOptional": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-compiler": { + "version": "250829098.0.14", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.14.tgz", + "integrity": "sha512-5meXwsZxgiqFaJjNzwjzI9IyUkuGGBisu+z9BvQWmGVpjH6nz11hgqkyxe4dl8UAdyIV4lTbz91+Dlnjz0VxqA==", + "license": "MIT" + }, + "node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "devOptional": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-document.all": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unsafe": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-1.0.1.tgz", + "integrity": "sha512-CLK2+VdgERgD96EYm5lUQssZYlRg2tkZnbsxZoacmSiRxiFJ4Nk4SzjCl+Ur+v3kXIY9dTIdb3IH22y1mZ56LA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "17.13.4", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.4.tgz", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "devOptional": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/launch-editor": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", + "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-fragments": "^0.2.1", + "dayjs": "^1.8.15", + "yargs": "^15.1.0" + }, + "bin": { + "logkitty": "bin/logkitty.js" + } + }, + "node_modules/logkitty/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/logkitty/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/logkitty/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/logkitty/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/logkitty/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/metro": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", + "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "image-size": "^1.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", + "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro-cache": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", + "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-cache-key": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", + "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-config": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", + "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.4", + "metro-cache": "0.84.4", + "metro-core": "0.84.4", + "metro-runtime": "0.84.4", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-core": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", + "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.4" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-file-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", + "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-minify-terser": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", + "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-resolver": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", + "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-runtime": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", + "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", + "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.4", + "nullthrows": "^1.1.1", + "ob1": "0.84.4", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-source-map/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-symbolicate": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", + "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.4", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-symbolicate/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro-transform-plugins": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", + "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-transform-worker": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", + "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-source-map": "0.84.4", + "metro-transform-plugins": "0.84.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/metro/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/metro/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "devOptional": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nitrogen": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/nitrogen/-/nitrogen-0.36.1.tgz", + "integrity": "sha512-0Q/YQLyE1/iNRNfSEl6vSqXUJEaQQv0GboWYan+Kiy5Vr6kE/F3CrKnvrnDcR6cgFeIYN++eON+Qn3djDVHYvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "react-native-nitro-modules": "^0.36.1", + "ts-morph": "^28.0.0", + "yargs": "^18.0.0", + "zod": "^4.4.3" + }, + "bin": { + "nitrogen": "lib/index.js" + } + }, + "node_modules/nitrogen/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/nitrogen/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/nitrogen/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/nitrogen/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/nitrogen/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/nitrogen/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/nocache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", + "integrity": "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", + "integrity": "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-stream-zip": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", + "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/antelle" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/ob1": { + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", + "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", + "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^1.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/phosphor-react-native": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/phosphor-react-native/-/phosphor-react-native-3.0.6.tgz", + "integrity": "sha512-mIhv+hBbDWz3yLbTOq/esVUkrMoKzEvLIcSeZo3YTdT+sbOBxzxKg5WZN3wpqt3pkkhKf0wVpOyZ3d9mNBNRhg==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-svg": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.86.0", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.0.tgz", + "integrity": "sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@react-native/assets-registry": "0.86.0", + "@react-native/codegen": "0.86.0", + "@react-native/community-cli-plugin": "0.86.0", + "@react-native/gradle-plugin": "0.86.0", + "@react-native/js-polyfills": "0.86.0", + "@react-native/normalize-colors": "0.86.0", + "@react-native/virtualized-lists": "0.86.0", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.14", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native/jest-preset": "0.86.0", + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@react-native/jest-preset": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-haptika-ppg": { + "resolved": "packages/react-native-haptika-ppg", + "link": true + }, + "node_modules/react-native-nitro-image": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-image/-/react-native-nitro-image-0.15.1.tgz", + "integrity": "sha512-slrImfUgasAdzylTiNWuTkawE0R4t7LGh+7N2VFa/D9+SxlQbkKYp1J6hA3XkkEnTnUCKaea39ZVg+1xx665MQ==", + "license": "MIT", + "peer": true, + "workspaces": [ + "example" + ], + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*" + } + }, + "node_modules/react-native-nitro-modules": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.36.1.tgz", + "integrity": "sha512-kBv/VvKqAmkXAvP1DxJMC9b/fRhh7JdSO4EUnPP46hJjrIFeFR8AwKm8mYaKZEuF014M/TVdv2vomVUW0umsQQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.5.2.tgz", + "integrity": "sha512-t4YVbHa9uAGf+pHMabGrb0uHrD5ogAusSu842oikJ3YKXcYp6iB4PTGl0EZNkUIR3pCnw/CXKn42OCfhsS0JIw==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg": { + "version": "15.15.5", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", + "integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==", + "license": "MIT", + "peer": true, + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-vision-camera": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-5.1.0.tgz", + "integrity": "sha512-1rsZ4caFLmOhQ9wediPuqd+WEdRzLZ2/tMfo4Itdeuyx4V/sPkerIgYT2DYYvyXbB8swvFgEW7ihIdnUkarqeA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-image": "*", + "react-native-nitro-modules": "*" + } + }, + "node_modules/react-native-vision-camera-worklets": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/react-native-vision-camera-worklets/-/react-native-vision-camera-worklets-5.1.0.tgz", + "integrity": "sha512-BqHsV7anweEHJhIByQUPolq3zKjJT7uJx64cKYbIVnkvo94EOcoMJDOzTgIAroDBjkDO+PqhDhURxCIUWUtv4Q==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*", + "react-native-vision-camera": "*", + "react-native-worklets": "*" + } + }, + "node_modules/react-native-worklets": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.10.2.tgz", + "integrity": "sha512-LX27ejYI8veeDp59Z3rjo2pYyPa9euzSH8GUlem7cnNqfsDtGum8PQpkbzrqhLsWH0CjdeHR7p3sncCyYbwaVw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/preset-typescript": "^7.28.5", + "@babel/types": "^7.27.1", + "convert-source-map": "^2.0.0", + "semver": "^7.7.4" + }, + "peerDependencies": { + "@babel/core": "*", + "@react-native/metro-config": "*", + "react": "*", + "react-native": "0.83 - 0.86" + } + }, + "node_modules/react-native-worklets/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-test-renderer": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-19.2.3.tgz", + "integrity": "sha512-TMR1LnSFiWZMJkCgNf5ATSvAheTT2NvKIwiVwdBPHxjBI7n/JbWd4gaZ16DVd9foAXdvDz+sB5yxZTwMjPRxpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "react-is": "^19.2.3", + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-test-renderer/node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "devOptional": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", + "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.0", + "astral-regex": "^1.0.0", + "is-fullwidth-code-point": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/slice-ansi/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/slice-ansi/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "devOptional": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/stacktrace-parser/node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-natural-compare": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz", + "integrity": "sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.11.tgz", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.10.tgz", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/terser/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-morph": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-28.0.0.tgz", + "integrity": "sha512-Wp3tnZ2bzwxyTZMtgWVzXDfm7lB1Drz+y9DmmYH/L702PQhPyVrp3pkou3yIz4qjS14GY9kcpmLiOOMvl8oG1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ts-morph/common": "~0.29.0", + "code-block-writer": "^13.0.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.8.tgz", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/ws": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.4.tgz", + "integrity": "sha512-PNIUUyLI5YpkJZj60YBzX1o0ByQ4ovvfmq9N/Kig/PAYbVlGyz4R6G0SEWrD0O9acc0sT2+IdMBVLFv8FSi0Nw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "async-limiter": "~1.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "packages/react-native-haptika-ppg": { + "version": "0.1.0", + "license": "UNLICENSED", + "devDependencies": { + "nitrogen": "0.36.1", + "react": "19.2.3", + "react-native": "0.86.0", + "react-native-nitro-modules": "0.36.1", + "react-native-vision-camera": "5.1.0", + "typescript": "^5.8.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*", + "react-native-vision-camera": "*" + } + } + } +} diff --git a/package.json b/package.json index ea7df3e..6c6d2f8 100644 --- a/package.json +++ b/package.json @@ -3,27 +3,39 @@ "version": "0.0.1", "private": true, "scripts": { - "android": "react-native run-android", "ios": "react-native run-ios", + "ios:pods": "cd ios && bundle exec pod install", "lint": "eslint .", + "native:specs": "cd packages/react-native-haptika-ppg && npx nitrogen .", "start": "react-native start", - "test": "jest" + "test": "jest", + "test:ci": "jest --runInBand", + "typecheck": "tsc --noEmit" }, "dependencies": { + "@react-native-async-storage/async-storage": "3.1.1", + "phosphor-react-native": "^3.0.6", "react": "19.2.3", "react-native": "0.86.0", - "@react-native/new-app-screen": "0.86.0", - "react-native-safe-area-context": "^5.5.2" + "react-native-haptika-ppg": "file:packages/react-native-haptika-ppg", + "react-native-nitro-image": "0.15.1", + "react-native-nitro-modules": "0.36.1", + "react-native-safe-area-context": "5.5.2", + "react-native-svg": "^15.15.5", + "react-native-vision-camera": "5.1.0", + "react-native-vision-camera-worklets": "5.1.0", + "react-native-worklets": "0.10.2" }, "devDependencies": { "@babel/core": "^7.25.2", "@babel/preset-env": "^7.25.3", "@babel/runtime": "^7.25.0", - "@react-native-community/cli": "20.1.0", - "@react-native-community/cli-platform-android": "20.1.0", - "@react-native-community/cli-platform-ios": "20.1.0", + "@react-native-community/cli": "^20.2.0", + "@react-native-community/cli-platform-android": "^20.2.0", + "@react-native-community/cli-platform-ios": "^20.2.0", "@react-native/babel-preset": "0.86.0", "@react-native/eslint-config": "0.86.0", + "@react-native/jest-preset": "^0.86.0", "@react-native/metro-config": "0.86.0", "@react-native/typescript-config": "0.86.0", "@types/jest": "^29.5.13", @@ -31,6 +43,7 @@ "@types/react-test-renderer": "^19.1.0", "eslint": "^8.19.0", "jest": "^29.6.3", + "nitrogen": "0.36.1", "prettier": "2.8.8", "react-test-renderer": "19.2.3", "typescript": "^5.8.3" @@ -38,4 +51,4 @@ "engines": { "node": ">= 22.11.0" } -} \ No newline at end of file +} diff --git a/packages/react-native-haptika-ppg/HaptikaPPG.podspec b/packages/react-native-haptika-ppg/HaptikaPPG.podspec new file mode 100644 index 0000000..6b7d362 --- /dev/null +++ b/packages/react-native-haptika-ppg/HaptikaPPG.podspec @@ -0,0 +1,30 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "HaptikaPPG" + s.version = package["version"] + s.summary = package["description"] + s.homepage = "https://haptika.local" + s.license = { :type => "Proprietary" } + s.authors = { "Haptika" => "engineering@haptika.local" } + s.platforms = { :ios => "16.0" } + s.source = { :path => "." } + # Nitro Modules 0.36.1 is not yet clean under Swift 6 strict concurrency. + s.swift_version = "5.0" + + s.source_files = [ + "ios/**/*.{swift,m,mm,cpp,h,hpp}", + "cpp/**/*.{cpp,h,hpp}" + ] + + load "nitrogen/generated/ios/HaptikaPPG+autolinking.rb" + add_nitrogen_files(s) + + s.frameworks = "AVFoundation", "Accelerate", "CoreVideo", "HealthKit" + s.dependency "VisionCamera" + s.dependency "React-jsi" + s.dependency "React-callinvoker" + install_modules_dependencies(s) +end diff --git a/packages/react-native-haptika-ppg/Package.swift b/packages/react-native-haptika-ppg/Package.swift new file mode 100644 index 0000000..ee3492d --- /dev/null +++ b/packages/react-native-haptika-ppg/Package.swift @@ -0,0 +1,47 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "HaptikaPPGNativeTests", + platforms: [ + .iOS(.v16), + .macOS(.v13), + ], + targets: [ + .target( + name: "HaptikaFrameMetrics", + path: "ios/FrameProcessor", + exclude: [ + "HybridHaptikaPPGFrameProcessor.swift", + "PPGFingerContactDetector.swift", + ], + sources: ["PPGFrameMetricsExtractor.swift"] + ), + .target( + name: "HaptikaSignal", + path: "ios/Signal" + ), + .target( + name: "HaptikaSafety", + path: "ios/Safety", + exclude: ["PPGThermalMonitor.swift"], + sources: ["PPGThermalSafetyPolicy.swift"] + ), + .testTarget( + name: "HaptikaFrameMetricsTests", + dependencies: ["HaptikaFrameMetrics"], + path: "Tests/HaptikaFrameMetricsTests" + ), + .testTarget( + name: "HaptikaSignalTests", + dependencies: ["HaptikaSignal"], + path: "Tests/HaptikaSignalTests" + ), + .testTarget( + name: "HaptikaSafetyTests", + dependencies: ["HaptikaSafety"], + path: "Tests/HaptikaSafetyTests" + ), + ] +) diff --git a/packages/react-native-haptika-ppg/Tests/HaptikaFrameMetricsTests/PPGFrameMetricsExtractorTests.swift b/packages/react-native-haptika-ppg/Tests/HaptikaFrameMetricsTests/PPGFrameMetricsExtractorTests.swift new file mode 100644 index 0000000..9f7539f --- /dev/null +++ b/packages/react-native-haptika-ppg/Tests/HaptikaFrameMetricsTests/PPGFrameMetricsExtractorTests.swift @@ -0,0 +1,341 @@ +import CoreMedia +import CoreVideo +import XCTest +@testable import HaptikaFrameMetrics + +final class PPGFrameMetricsExtractorTests: XCTestCase { + func testFullRangeYUVExtractionUsesCentralROIAndUnlocksBuffer() throws { + let pixelBuffer = try makePixelBuffer( + width: 64, + height: 64, + pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange + ) + try fillBiPlanarYUV(pixelBuffer, y: 100, cb: 128, cr: 128) + let metrics = try PPGFrameMetricsExtractor(gridSize: 16, roiFraction: 0.5) + .extract(from: makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 4.25)) + + XCTAssertEqual(metrics.timestampSeconds, 4.25, accuracy: 0.000_001) + XCTAssertEqual(metrics.redMean, 100, accuracy: 0.000_001) + XCTAssertEqual(metrics.greenMean, 100, accuracy: 0.000_001) + XCTAssertEqual(metrics.blueMean, 100, accuracy: 0.000_001) + XCTAssertEqual(metrics.luminanceMean, 100, accuracy: 0.000_001) + try assertCanRelock(pixelBuffer) + } + + func testVideoRangeYUVExtractionAppliesVideoRangeConversion() throws { + let pixelBuffer = try makePixelBuffer( + width: 48, + height: 48, + pixelFormat: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange + ) + try fillBiPlanarYUV(pixelBuffer, y: 126, cb: 128, cr: 128) + let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5) + .extract(from: makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 2)) + + let expected = 1.164_383 * 110 + XCTAssertEqual(metrics.redMean, expected, accuracy: 0.001) + XCTAssertEqual(metrics.greenMean, expected, accuracy: 0.001) + XCTAssertEqual(metrics.blueMean, expected, accuracy: 0.001) + try assertCanRelock(pixelBuffer) + } + + func testBGRAExtractionSamplesOnlyCentralROI() throws { + let pixelBuffer = try makePixelBuffer( + width: 64, + height: 64, + pixelFormat: kCVPixelFormatType_32BGRA + ) + try fillBGRA(pixelBuffer) { x, y in + if (16..<48).contains(x), (16..<48).contains(y) { + return (blue: 20, green: 80, red: 200, alpha: 255) + } + return (blue: 240, green: 10, red: 5, alpha: 255) + } + let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 12.5) + + let metrics = try PPGFrameMetricsExtractor(gridSize: 16, roiFraction: 0.5) + .extract(from: sampleBuffer) + + XCTAssertEqual(metrics.timestampSeconds, 12.5, accuracy: 0.000_001) + XCTAssertEqual(metrics.redMean, 200, accuracy: 0.000_001) + XCTAssertEqual(metrics.greenMean, 80, accuracy: 0.000_001) + XCTAssertEqual(metrics.blueMean, 20, accuracy: 0.000_001) + XCTAssertEqual(metrics.redVariance, 0, accuracy: 0.000_001) + XCTAssertEqual(metrics.greenVariance, 0, accuracy: 0.000_001) + XCTAssertEqual( + metrics.luminanceMean, + 0.2126 * 200 + 0.7152 * 80 + 0.0722 * 20, + accuracy: 0.000_001 + ) + XCTAssertEqual(metrics.saturationRatio, 0, accuracy: 0.000_001) + try assertCanRelock(pixelBuffer) + } + + func testBGRARedOnlyClippingKeepsUsableGreenChannel() throws { + let pixelBuffer = try makePixelBuffer( + width: 48, + height: 48, + pixelFormat: kCVPixelFormatType_32BGRA + ) + try fillBGRA(pixelBuffer) { x, y in + let isCentral = (12..<36).contains(x) && (12..<36).contains(y) + return isCentral + ? (blue: 40, green: 100, red: 255, alpha: 255) + : (blue: 0, green: 0, red: 0, alpha: 255) + } + let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 1) + + let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5) + .extract(from: sampleBuffer) + + XCTAssertEqual(metrics.redMean, 255, accuracy: 0.000_001) + XCTAssertEqual(metrics.saturationRatio, 0, accuracy: 0.000_001) + try assertCanRelock(pixelBuffer) + } + + func testBGRASaturationRequiresBothUsefulChannelsToClip() throws { + let pixelBuffer = try makePixelBuffer( + width: 48, + height: 48, + pixelFormat: kCVPixelFormatType_32BGRA + ) + try fillBGRA(pixelBuffer) { _, _ in + (blue: 80, green: 255, red: 255, alpha: 255) + } + let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 1) + + let metrics = try PPGFrameMetricsExtractor(gridSize: 12, roiFraction: 0.5) + .extract(from: sampleBuffer) + + XCTAssertEqual(metrics.saturationRatio, 1, accuracy: 0.000_001) + try assertCanRelock(pixelBuffer) + } + + func testUnsupportedPixelFormatUnlocksBufferOnThrow() throws { + let pixelBuffer = try makePixelBuffer( + width: 32, + height: 32, + pixelFormat: kCVPixelFormatType_OneComponent8 + ) + let sampleBuffer = try makeSampleBuffer(pixelBuffer: pixelBuffer, timestampSeconds: 2) + + XCTAssertThrowsError(try PPGFrameMetricsExtractor().extract(from: sampleBuffer)) { error in + guard case PPGFrameMetricsExtractorError.unsupportedPixelFormat(let format) = error else { + return XCTFail("Expected unsupportedPixelFormat, got \(error)") + } + XCTAssertEqual(format, kCVPixelFormatType_OneComponent8) + } + try assertCanRelock(pixelBuffer) + } + + func testRepeatedExtractionDoesNotLeavePixelBuffersLocked() throws { + let extractor = PPGFrameMetricsExtractor(gridSize: 8, roiFraction: 0.5) + for index in 0..<100 { + autoreleasepool { + do { + let pixelBuffer = try makePixelBuffer( + width: 32, + height: 32, + pixelFormat: kCVPixelFormatType_32BGRA + ) + try fillBGRA(pixelBuffer) { _, _ in (20, 90, 180, 255) } + _ = try extractor.extract( + from: makeSampleBuffer( + pixelBuffer: pixelBuffer, + timestampSeconds: Double(index) / 30 + ) + ) + try assertCanRelock(pixelBuffer) + } catch { + XCTFail("Repeated extraction failed at \(index): \(error)") + } + } + } + } + + private func makePixelBuffer( + width: Int, + height: Int, + pixelFormat: OSType + ) throws -> CVPixelBuffer { + if pixelFormat == kCVPixelFormatType_420YpCbCr8BiPlanarFullRange + || pixelFormat == kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange { + return try makeBiPlanarPixelBuffer(width: width, height: height, pixelFormat: pixelFormat) + } + var pixelBuffer: CVPixelBuffer? + let status = CVPixelBufferCreate( + kCFAllocatorDefault, + width, + height, + pixelFormat, + nil, + &pixelBuffer + ) + guard status == kCVReturnSuccess, let pixelBuffer else { + throw TestBufferError.pixelBufferCreation(status) + } + return pixelBuffer + } + + private func makeBiPlanarPixelBuffer( + width: Int, + height: Int, + pixelFormat: OSType + ) throws -> CVPixelBuffer { + let yBytesPerRow = width + let uvBytesPerRow = width + let ySize = yBytesPerRow * height + let uvHeight = height / 2 + let totalSize = ySize + uvBytesPerRow * uvHeight + guard let storage = malloc(totalSize) else { + throw TestBufferError.missingBaseAddress + } + var planeAddresses: [UnsafeMutableRawPointer?] = [ + storage, + storage.advanced(by: ySize), + ] + var planeWidths = [width, width / 2] + var planeHeights = [height, uvHeight] + var planeBytesPerRow = [yBytesPerRow, uvBytesPerRow] + var pixelBuffer: CVPixelBuffer? + let status = CVPixelBufferCreateWithPlanarBytes( + kCFAllocatorDefault, + width, + height, + pixelFormat, + storage, + totalSize, + 2, + &planeAddresses, + &planeWidths, + &planeHeights, + &planeBytesPerRow, + { _, dataPointer, _, _, _ in + free(UnsafeMutableRawPointer(mutating: dataPointer)) + }, + nil, + nil, + &pixelBuffer + ) + guard status == kCVReturnSuccess, let pixelBuffer else { + free(storage) + throw TestBufferError.pixelBufferCreation(status) + } + return pixelBuffer + } + + private func fillBiPlanarYUV( + _ pixelBuffer: CVPixelBuffer, + y: UInt8, + cb: UInt8, + cr: UInt8 + ) throws { + let status = CVPixelBufferLockBaseAddress(pixelBuffer, []) + guard status == kCVReturnSuccess else { + throw TestBufferError.pixelBufferLock(status) + } + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } + guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2, + let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0), + let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) + else { + throw TestBufferError.missingBaseAddress + } + let yHeight = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0) + let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0) + let yBytes = yBase.assumingMemoryBound(to: UInt8.self) + for row in 0.. (blue: UInt8, green: UInt8, red: UInt8, alpha: UInt8) + ) throws { + let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, []) + guard lockStatus == kCVReturnSuccess else { + throw TestBufferError.pixelBufferLock(lockStatus) + } + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) } + + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { + throw TestBufferError.missingBaseAddress + } + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let bytes = baseAddress.assumingMemoryBound(to: UInt8.self) + + for y in 0.. CMSampleBuffer { + var formatDescription: CMVideoFormatDescription? + let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescriptionOut: &formatDescription + ) + guard formatStatus == noErr, let formatDescription else { + throw TestBufferError.formatDescriptionCreation(formatStatus) + } + + var timing = CMSampleTimingInfo( + duration: .invalid, + presentationTimeStamp: CMTime(seconds: timestampSeconds, preferredTimescale: 60_000), + decodeTimeStamp: .invalid + ) + var sampleBuffer: CMSampleBuffer? + let sampleStatus = CMSampleBufferCreateReadyWithImageBuffer( + allocator: kCFAllocatorDefault, + imageBuffer: pixelBuffer, + formatDescription: formatDescription, + sampleTiming: &timing, + sampleBufferOut: &sampleBuffer + ) + guard sampleStatus == noErr, let sampleBuffer else { + throw TestBufferError.sampleBufferCreation(sampleStatus) + } + return sampleBuffer + } + + private func assertCanRelock(_ pixelBuffer: CVPixelBuffer) throws { + let status = CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) + guard status == kCVReturnSuccess else { + throw TestBufferError.pixelBufferLock(status) + } + CVPixelBufferUnlockBaseAddress(pixelBuffer, .readOnly) + } +} + +private enum TestBufferError: Error { + case pixelBufferCreation(CVReturn) + case pixelBufferLock(CVReturn) + case missingBaseAddress + case formatDescriptionCreation(OSStatus) + case sampleBufferCreation(OSStatus) +} diff --git a/packages/react-native-haptika-ppg/Tests/HaptikaSafetyTests/PPGThermalSafetyPolicyTests.swift b/packages/react-native-haptika-ppg/Tests/HaptikaSafetyTests/PPGThermalSafetyPolicyTests.swift new file mode 100644 index 0000000..ee3910f --- /dev/null +++ b/packages/react-native-haptika-ppg/Tests/HaptikaSafetyTests/PPGThermalSafetyPolicyTests.swift @@ -0,0 +1,16 @@ +import XCTest +@testable import HaptikaSafety + +final class PPGThermalSafetyPolicyTests: XCTestCase { + func testSeriousAndCriticalStatesStopMeasurement() { + XCTAssertTrue(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "serious")) + XCTAssertTrue(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "critical")) + } + + func testNominalFairAndUnknownStatesRemainAvailable() { + XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "nominal")) + XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "fair")) + XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "unknown")) + XCTAssertFalse(PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: "future-state")) + } +} diff --git a/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGCoherencePolicyTests.swift b/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGCoherencePolicyTests.swift new file mode 100644 index 0000000..10d1133 --- /dev/null +++ b/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGCoherencePolicyTests.swift @@ -0,0 +1,52 @@ +import XCTest +@testable import HaptikaSignal + +final class PPGCoherencePolicyTests: XCTestCase { + func testSessionDurationLimitsRemainPurposeSpecific() { + XCTAssertEqual(PPGCoherencePolicy.maximumDurationSeconds(isCoherence: false), 120) + XCTAssertEqual(PPGCoherencePolicy.maximumDurationSeconds(isCoherence: true), 300) + XCTAssertEqual(PPGCoherencePolicy.coherenceBufferDurationSeconds, 75) + } + + func testLiveTrackerPublishesEveryNewBeatOnlyOnce() { + var tracker = PPGLiveBeatTracker() + let first = tracker.ingest([ + interval(end: 1, duration: 800), interval(end: 1.8, duration: 800), + ]) + XCTAssertEqual(first.map(\.sequence), [1, 2]) + let overlapping = tracker.ingest([ + interval(end: 1.82, duration: 800), interval(end: 2.6, duration: 780), + ]) + XCTAssertEqual(overlapping.map(\.sequence), [3]) + XCTAssertEqual(overlapping.first?.timestampSeconds, 2.6) + } + + func testTrackerDoesNotMoveBackwardAndResetsCleanly() { + var tracker = PPGLiveBeatTracker() + _ = tracker.ingest([interval(end: 10, duration: 700)]) + XCTAssertTrue(tracker.ingest([interval(end: 9.9, duration: 700)]).isEmpty) + tracker.reset() + XCTAssertEqual(tracker.ingest([interval(end: 1, duration: 700)]).first?.sequence, 1) + } + + func testTrackerRejectsInvalidAndRejectedIntervals() { + var tracker = PPGLiveBeatTracker() + let rejected = PulseInterval( + sourceIndex: 0, startSeconds: 0, endSeconds: 1, + durationMilliseconds: 800, rejectionReason: .localOutlier, qualityScore: 1 + ) + XCTAssertTrue(tracker.ingest([ + rejected, interval(end: .nan, duration: 800), interval(end: 2, duration: 2_500), + ]).isEmpty) + } + + private func interval(end: Double, duration: Double) -> PulseInterval { + PulseInterval( + sourceIndex: 0, + startSeconds: end - duration / 1_000, + endSeconds: end, + durationMilliseconds: duration, + qualityScore: 0.9 + ) + } +} diff --git a/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGSignalPipelineTests.swift b/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGSignalPipelineTests.swift new file mode 100644 index 0000000..6892cf9 --- /dev/null +++ b/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGSignalPipelineTests.swift @@ -0,0 +1,247 @@ +import XCTest +@testable import HaptikaSignal + +final class PPGSignalPipelineTests: XCTestCase { + func testPRVCalculatorDerivesIntervalMetrics() throws { + let intervals = [700.0, 720, 760, 710, 800, 730, 750, 705] + let metrics = try PPGPRVCalculator.calculate( + intervalsMilliseconds: intervals, + configuration: PPGProcessingConfiguration(minimumIntervalsForPRV: 5) + ) + + XCTAssertEqual(metrics.meanIpiMs ?? 0, 734.375, accuracy: 0.001) + XCTAssertEqual(metrics.medianIpiMs ?? 0, 725, accuracy: 0.001) + XCTAssertEqual(metrics.pnn50Percent ?? 0, 200.0 / 7.0, accuracy: 0.001) + XCTAssertEqual(metrics.rangeIpiMs ?? 0, 100, accuracy: 0.001) + XCTAssertEqual(metrics.modeIpiMs ?? 0, 713, accuracy: 0.001) + XCTAssertEqual(metrics.amo50Percent ?? 0, 62.5, accuracy: 0.001) + XCTAssertEqual((metrics.coefficientOfVariation ?? 0) * 100, 4.68, accuracy: 0.1) + } + + func testBPMEstimatorCorrectsSecondHarmonic() throws { + let sampleRate = 30.0 + let duration = 30.0 + let sampleCount = Int(sampleRate * duration) + let timestamps = (0.. PPGFrameSample in + let time = Double(index) / sampleRate + let pulse = sin(2 * Double.pi * 1.2 * time) + + 0.18 * sin(2 * Double.pi * 2.4 * time) + let slowLightDrift = 1 + 0.08 * sin(2 * Double.pi * 0.08 * time) + return PPGFrameSample( + timestampSeconds: time, + red: 185 * slowLightDrift + 2.5 * pulse, + green: 82 * slowLightDrift + 5.5 * pulse, + luminance: 112 * slowLightDrift + 4 * pulse, + contactConfidence: 0.9, + saturationRatio: 0, + motionArtifactScore: 0.02 + ) + } + let configuration = PPGProcessingConfiguration( + minimumAnalysisDurationSeconds: 45, + filterTapCount: 61 + ) + + let result = try PPGSignalProcessor(configuration: configuration) + .analyze(samples: samples) + + XCTAssertEqual(result.status, .valid) + XCTAssertNotNil(result.bpm.bpm) + XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3) + XCTAssertGreaterThan(result.quality.score, 0.65) + } + + func testResamplerKeepsUniformGridAndMasksLongGap() throws { + let input = [ + TimestampedValue(timestampSeconds: 0.00, value: 0), + TimestampedValue(timestampSeconds: 0.09, value: 1), + TimestampedValue(timestampSeconds: 0.21, value: 2), + TimestampedValue(timestampSeconds: 0.80, value: 3), + TimestampedValue(timestampSeconds: 0.91, value: 4), + ] + let result = try PPGSignalResampler.resample( + input, + targetSampleRateHz: 10, + maximumInterpolationGapSeconds: 0.20 + ) + + XCTAssertEqual(result.timestampsSeconds.count, result.values.count) + XCTAssertEqual(result.timestampsSeconds[1] - result.timestampsSeconds[0], 0.1, accuracy: 1e-9) + XCTAssertFalse(result.validityMask[4], "A long camera gap must remain unusable") + XCTAssertGreaterThan(result.diagnostics.droppedFrameRatio, 0) + } + + func testPRVLocalFilterRejectsArtifactWithoutRejectingGradualChange() throws { + let intervals = [700.0, 710, 720, 730, 740, 400, 760, 770, 780, 790, 800] + let result = try PPGPRVCalculator.calculate( + intervals: makeIntervals(intervals), + configuration: PPGProcessingConfiguration( + minimumIntervalsForPRV: 5, + maximumRejectedIntervalRatioForPRV: 0.25 + ) + ) + + XCTAssertEqual(result.intervals[5].rejectionReason, .localOutlier) + XCTAssertTrue(result.intervals[10].isAccepted, "A gradual HR change must remain usable") + XCTAssertEqual(result.metrics.validIntervalCount, intervals.count - 1) + XCTAssertTrue(result.metrics.reliable) + } + + func testProcessorCanRecoverPulseCarriedByBlueChrominance() throws { + let sampleRate = 30.0 + let duration = 55.0 + let samples = (0...Int(sampleRate * duration)).map { index -> PPGFrameSample in + let time = Double(index) / sampleRate + let pulse = sin(2 * Double.pi * 1.2 * time) + + 0.20 * sin(2 * Double.pi * 2.4 * time) + let red = 150.0 + let green = 100.0 + let blue = 80 + 9 * pulse + let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue + return PPGFrameSample( + timestampSeconds: time, + red: red, + green: green, + luminance: luminance, + contactConfidence: 0.95, + saturationRatio: 0, + motionArtifactScore: 0 + ) + } + let result = try PPGSignalProcessor( + configuration: PPGProcessingConfiguration( + minimumAnalysisDurationSeconds: 45, + filterTapCount: 61 + ) + ).analyze(samples: samples) + + XCTAssertEqual(result.status, .valid) + XCTAssertTrue( + [.blue, .greenRedRatio, .blueRedRatio, .normalizedBlend] + .contains(result.bpm.selectedChannel) + ) + XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3) + } + + func testCoachingContactScoreDoesNotDiscardARealPeriodicSignal() throws { + let sampleRate = 30.0 + let duration = 55.0 + let samples = (0...Int(sampleRate * duration)).map { index -> PPGFrameSample in + let time = Double(index) / sampleRate + let pulse = sin(2 * Double.pi * 1.25 * time) + + 0.16 * sin(2 * Double.pi * 2.5 * time) + return PPGFrameSample( + timestampSeconds: time, + red: 142 + 3.2 * pulse, + green: 104 + 5.8 * pulse, + blue: 76 + 1.4 * pulse, + luminance: 111 + 4.9 * pulse, + contactConfidence: 0.10, + saturationRatio: 0, + motionArtifactScore: 0.02 + ) + } + + let result = try PPGSignalProcessor( + configuration: PPGProcessingConfiguration( + minimumAnalysisDurationSeconds: 45, + filterTapCount: 61 + ) + ).analyze(samples: samples) + + XCTAssertEqual(result.status, .valid) + XCTAssertEqual(result.bpm.bpm ?? 0, 75, accuracy: 3) + XCTAssertTrue(result.quality.issues.contains(.poorFingerContact)) + } + + func testBeatMorphologyScoresDistortedCycleWithoutLosingCleanBeats() throws { + let sampleRate = 30.0 + let periodSamples = 25 + let sampleCount = periodSamples * 14 + 1 + var signal = (0.. [PulseInterval] { + var elapsed = 0.0 + return durations.enumerated().map { index, duration in + defer { elapsed += duration / 1_000 } + return PulseInterval( + sourceIndex: index, + startSeconds: elapsed, + endSeconds: elapsed + duration / 1_000, + durationMilliseconds: duration + ) + } + } +} diff --git a/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGSyntheticSignalTests.swift b/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGSyntheticSignalTests.swift new file mode 100644 index 0000000..fafb932 --- /dev/null +++ b/packages/react-native-haptika-ppg/Tests/HaptikaSignalTests/PPGSyntheticSignalTests.swift @@ -0,0 +1,202 @@ +import XCTest +@testable import HaptikaSignal + +final class PPGSyntheticSignalTests: XCTestCase { + private let processorConfiguration = PPGProcessingConfiguration( + minimumAnalysisDurationSeconds: 45, + filterTapCount: 61 + ) + + func testPublicationThresholdsRemainOrderedByScientificDemand() { + XCTAssertEqual(PPGPublicationThresholds.bpmQualityScore, 0.45) + XCTAssertEqual(PPGPublicationThresholds.potentialIndexQualityScore, 0.65) + XCTAssertEqual(PPGPublicationThresholds.prvQualityScore, 0.80) + XCTAssertLessThan( + PPGPublicationThresholds.bpmQualityScore, + PPGPublicationThresholds.potentialIndexQualityScore + ) + XCTAssertLessThan( + PPGPublicationThresholds.potentialIndexQualityScore, + PPGPublicationThresholds.prvQualityScore + ) + } + + func testGeneratorIsDeterministicAndAppliesAllConfiguredArtifacts() throws { + let configuration = PPGSyntheticSignalConfiguration( + durationSeconds: 10, + startingBPM: 72, + intervalVariationMilliseconds: [-20, 20], + noiseAmplitude: 1.5, + driftAmplitude: 0.05, + movementWindows: [.init(startSeconds: 2, endSeconds: 3, intensity: 0.8)], + saturationWindows: [.init(startSeconds: 5, endSeconds: 6)], + dropEveryNthFrame: 10, + missingBeatIndices: [3], + falsePeakTimesSeconds: [7.25], + seed: 42 + ) + let first = try PPGSyntheticSignalGenerator.generate(configuration: configuration) + let second = try PPGSyntheticSignalGenerator.generate(configuration: configuration) + + XCTAssertEqual(first, second) + XCTAssertLessThan(first.samples.count, 301) + XCTAssertTrue(first.samples.contains { $0.motionArtifactScore >= 0.8 }) + XCTAssertTrue(first.samples.contains { $0.saturationRatio == 1 }) + XCTAssertEqual(first.nominalIntervalsMilliseconds[0], 60_000 / 72 - 20, accuracy: 0.001) + XCTAssertEqual(first.nominalIntervalsMilliseconds[1], 60_000 / 72 + 20, accuracy: 0.001) + } + + func testEndToEndCleanSignalsAt60_75And120BPM() throws { + let processor = try PPGSignalProcessor(configuration: processorConfiguration) + for expectedBPM in [60.0, 75.0, 120.0] { + let fixture = try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration( + durationSeconds: 60, + startingBPM: expectedBPM, + noiseAmplitude: 0.25, + driftAmplitude: 0.04, + seed: UInt64(expectedBPM) + ) + ) + let result = try processor.analyze(samples: fixture.samples) + XCTAssertEqual(result.status, .valid, "Expected a valid \(expectedBPM) BPM fixture") + XCTAssertEqual(result.bpm.bpm ?? 0, expectedBPM, accuracy: 3) + XCTAssertFalse(result.quality.issues.contains(.nonFiniteInput)) + } + } + + func testModerateNoiseDriftAndDroppedFramesRemainAnalyzable() throws { + let fixture = try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration( + durationSeconds: 60, + startingBPM: 78, + noiseAmplitude: 1.2, + driftAmplitude: 0.10, + dropEveryNthFrame: 20, + seed: 77 + ) + ) + let result = try PPGSignalProcessor(configuration: processorConfiguration) + .analyze(samples: fixture.samples) + + XCTAssertEqual(result.bpm.bpm ?? 0, 78, accuracy: 4) + XCTAssertGreaterThan(result.timebase.droppedFrameRatio, 0) + } + + func testMovementAndSaturationAreReportedInsteadOfProducingSilentPRV() throws { + let fixture = try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration( + durationSeconds: 60, + startingBPM: 72, + noiseAmplitude: 0.5, + movementWindows: [.init(startSeconds: 10, endSeconds: 45, intensity: 1)], + saturationWindows: [.init(startSeconds: 8, endSeconds: 52, intensity: 1)], + seed: 91 + ) + ) + let result = try PPGSignalProcessor(configuration: processorConfiguration) + .analyze(samples: fixture.samples) + + XCTAssertTrue(result.quality.issues.contains(.motionArtifacts)) + XCTAssertTrue(result.quality.issues.contains(.excessiveSaturation)) + XCTAssertFalse(result.prv.reliable) + } + + func testMissingAndFalsePeaksDoNotCreateNonFiniteMetrics() throws { + let fixture = try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration( + durationSeconds: 60, + startingBPM: 75, + noiseAmplitude: 0.4, + missingBeatIndices: [8, 22, 41], + falsePeakTimesSeconds: [12.30, 31.15, 46.40], + seed: 121 + ) + ) + let result = try PPGSignalProcessor(configuration: processorConfiguration) + .analyze(samples: fixture.samples) + + XCTAssertTrue(result.bpm.confidence.isFinite) + XCTAssertTrue(result.quality.score.isFinite) + XCTAssertTrue(result.intervals.allSatisfy { $0.durationMilliseconds.isFinite }) + XCTAssertGreaterThan(result.bpm.rejectedPeakCount + result.prv.rejectedIntervalCount, 0) + } + + func testProgressiveBPMChangeStaysInsideGeneratedRange() throws { + let fixture = try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration( + durationSeconds: 60, + startingBPM: 60, + endingBPM: 90, + noiseAmplitude: 0.2, + seed: 3 + ) + ) + let result = try PPGSignalProcessor(configuration: processorConfiguration) + .analyze(samples: fixture.samples) + + XCTAssertNotNil(result.bpm.bpm) + XCTAssertTrue((60...90).contains(result.bpm.bpm ?? 0)) + XCTAssertGreaterThan(fixture.nominalIntervalsMilliseconds.first ?? 0, 950) + XCTAssertLessThan(fixture.nominalIntervalsMilliseconds.last ?? 1_000, 700) + } + + func testKnownIntervalVariabilityProducesExpectedRMSSD() throws { + let offsets = [-30.0, 30.0] + let fixture = try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration( + durationSeconds: 60, + startingBPM: 75, + intervalVariationMilliseconds: offsets + ) + ) + let metrics = try PPGPRVCalculator.calculate( + intervalsMilliseconds: Array(fixture.nominalIntervalsMilliseconds.prefix(60)), + configuration: PPGProcessingConfiguration(minimumIntervalsForPRV: 20) + ) + + XCTAssertEqual(metrics.estimatedRmssdMs ?? 0, 60, accuracy: 0.001) + XCTAssertTrue(metrics.reliable) + } + + func testNaNAndInfinityFramesAreDiscardedExhaustively() throws { + var fixture = try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration(durationSeconds: 60, startingBPM: 72) + ) + fixture.samples.insert( + PPGFrameSample(timestampSeconds: .nan, red: 1, green: 1, luminance: 1), + at: 20 + ) + fixture.samples.insert( + PPGFrameSample(timestampSeconds: 2, red: .infinity, green: 1, luminance: 1), + at: 40 + ) + fixture.samples.insert( + PPGFrameSample(timestampSeconds: 3, red: 1, green: -.infinity, luminance: 1), + at: 60 + ) + fixture.samples.insert( + PPGFrameSample(timestampSeconds: 4, red: 1, green: 1, luminance: .nan), + at: 80 + ) + + let result = try PPGSignalProcessor(configuration: processorConfiguration) + .analyze(samples: fixture.samples) + XCTAssertEqual(result.timebase.discardedSampleCount, 4) + XCTAssertTrue(result.quality.issues.contains(.nonFiniteInput)) + XCTAssertEqual(result.bpm.bpm ?? 0, 72, accuracy: 3) + } + + func testGeneratorRejectsUnsafeConfiguration() { + XCTAssertThrowsError( + try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration(sampleRateHz: .nan) + ) + ) + XCTAssertThrowsError( + try PPGSyntheticSignalGenerator.generate( + configuration: PPGSyntheticSignalConfiguration(dropEveryNthFrame: 1) + ) + ) + } +} diff --git a/packages/react-native-haptika-ppg/ios/FrameProcessor/HybridHaptikaPPGFrameProcessor.swift b/packages/react-native-haptika-ppg/ios/FrameProcessor/HybridHaptikaPPGFrameProcessor.swift new file mode 100644 index 0000000..337f86e --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/FrameProcessor/HybridHaptikaPPGFrameProcessor.swift @@ -0,0 +1,887 @@ +import CoreMedia +import Foundation +import NitroModules +import VisionCamera + +/// Nitro entry point for camera PPG processing. +/// +/// A `HybridFrameSpec` and its `CMSampleBuffer` exist only as local values during +/// `processFrame(frame:)`. Persistent state contains compact numeric metrics only. +final class HybridHaptikaPPGFrameProcessor: HybridHaptikaPPGFrameProcessorSpec { + private enum SessionPhase { + case idle + case active + case thermalStopped + case finishing + } + + private struct MeasurementConfiguration: Sendable { + let sessionPurpose: PPGSessionPurpose + let targetSampleRate: Double + let minimumFrequencyHz: Double + let maximumFrequencyHz: Double + let stabilizationSeconds: Double + let measurementSeconds: Double + let emitEveryFrames: Int + let minimumContactConfidence: Double + let maximumInterpolationGapSeconds: Double + let longGapLimitSeconds: Double + let maximumBufferedSampleCount: Int + + init(native: NativePPGConfiguration) throws { + guard native.targetSampleRate.isFinite, + native.targetSampleRate >= 20, + native.targetSampleRate <= 120 + else { + throw HaptikaPPGFrameProcessorError.invalidConfiguration( + "targetSampleRate must be between 20 and 120 Hz" + ) + } + guard native.minimumFrequencyHz.isFinite, + native.maximumFrequencyHz.isFinite, + native.minimumFrequencyHz > 0, + native.maximumFrequencyHz > native.minimumFrequencyHz, + native.maximumFrequencyHz < native.targetSampleRate / 2 + else { + throw HaptikaPPGFrameProcessorError.invalidConfiguration( + "the physiological band must be ordered and below Nyquist" + ) + } + guard native.stabilizationSeconds.isFinite, + native.stabilizationSeconds >= 0, + native.stabilizationSeconds <= 30 + else { + throw HaptikaPPGFrameProcessorError.invalidConfiguration( + "stabilizationSeconds must be between 0 and 30" + ) + } + let maximumDuration = PPGCoherencePolicy.maximumDurationSeconds( + isCoherence: native.sessionPurpose == .coherence + ) + guard native.measurementSeconds.isFinite, + native.measurementSeconds >= 20, + native.measurementSeconds <= maximumDuration + else { + throw HaptikaPPGFrameProcessorError.invalidConfiguration( + "measurementSeconds exceeds the limit for this session purpose" + ) + } + guard native.emitEveryFrames.isFinite, + native.emitEveryFrames >= 1, + native.emitEveryFrames.rounded() == native.emitEveryFrames, + native.emitEveryFrames <= 10_000 + else { + throw HaptikaPPGFrameProcessorError.invalidConfiguration( + "emitEveryFrames must be a positive integer" + ) + } + guard native.minimumContactConfidence.isFinite, + (0...1).contains(native.minimumContactConfidence) + else { + throw HaptikaPPGFrameProcessorError.invalidConfiguration( + "minimumContactConfidence must be between 0 and 1" + ) + } + + sessionPurpose = native.sessionPurpose + targetSampleRate = native.targetSampleRate + minimumFrequencyHz = native.minimumFrequencyHz + maximumFrequencyHz = native.maximumFrequencyHz + stabilizationSeconds = native.stabilizationSeconds + measurementSeconds = native.measurementSeconds + emitEveryFrames = Int(native.emitEveryFrames) + minimumContactConfidence = native.minimumContactConfidence + maximumInterpolationGapSeconds = 0.25 + longGapLimitSeconds = 0.80 + + // This bound permits a full measurement plus recovery time after contact interruptions. + let maximumBufferedDuration = sessionPurpose == .coherence + ? PPGCoherencePolicy.coherenceBufferDurationSeconds + : stabilizationSeconds + measurementSeconds + 60 + maximumBufferedSampleCount = max( + Int((targetSampleRate * maximumBufferedDuration).rounded(.up)), + 256 + ) + } + + var coreConfiguration: PPGProcessingConfiguration { + PPGProcessingConfiguration( + targetSampleRateHz: targetSampleRate, + minimumFrequencyHz: minimumFrequencyHz, + maximumFrequencyHz: maximumFrequencyHz, + // The UI may finish as soon as 50 clean beats have been collected. + // Keep a 45 s floor for HRV while allowing completion before the 90 s + // safety limit on a clean signal. + minimumAnalysisDurationSeconds: min(max(30, measurementSeconds * 0.75), 45), + maximumInterpolationGapSeconds: maximumInterpolationGapSeconds, + longGapLimitSeconds: longGapLimitSeconds, + minimumContactConfidence: minimumContactConfidence, + maximumSaturationRatio: 0.95, + maximumMotionArtifactScore: 0.95, + minimumPeakProminence: 0.35, + minimumPeakAmplitude: 0.10, + refractoryPeriodSeconds: 0.25, + maximumMethodDisagreementBPM: 8, + minimumQualityScore: 0.55, + minimumIntervalsForPRV: 35, + maximumRejectedIntervalRatioForPRV: 0.45 + ) + } + } + + private struct SessionSnapshot: Sendable { + let configuration: MeasurementConfiguration + let samples: [PPGFrameSample] + let startedAtMonotonicSeconds: Double + let endedAtMonotonicSeconds: Double + let processedFrameCount: Int + let droppedFrameCount: Int + let acquisitionDroppedFrameCount: Int + let usableDurationSeconds: Double + let maximumAcquisitionGapSeconds: Double + let bufferLimitReached: Bool + let terminalReason: String? + } + + private let stateLock = NSLock() + private let metricsExtractor = PPGFrameMetricsExtractor() + private let thermalMonitor = PPGThermalMonitor() + + private var phase: SessionPhase = .idle + private var configuration: MeasurementConfiguration? + private var contactDetector: PPGFingerContactDetector? + private var terminalThermalState: ThermalState? + private var numericSamples: [PPGFrameSample] = [] + private var startedAtMonotonicSeconds = 0.0 + private var terminalReason: String? + private var bufferLimitReached = false + + private var processedFrameCount = 0 + private var droppedFrameCount = 0 + private var acquisitionDroppedFrameCount = 0 + private var lastObservedTimestamp: Double? + private var recentFrameIntervals: [Double] = [] + private var maximumAcquisitionGapSeconds = 0.0 + + private var validContactStartedAt: Double? + private var acquisitionStartedAt: Double? + private var lastUsableTimestamp: Double? + private var usableDurationSeconds = 0.0 + + private var lastMetrics: PPGFrameColorMetrics? + private var lastContactEvaluation: PPGContactEvaluation? + private var liveBpm: Double? + private var liveIpiMs: Double? + private var liveBeatSequence = 0 + private var lastLiveAnalysisFrame = 0 + private var liveBeatTracker = PPGLiveBeatTracker() + private var pendingLiveBeats: [NativeAcceptedBeat] = [] + private var liveWindowQuality: Double? + private var totalCollectedSampleCount = 0 + + override init() { + recentFrameIntervals.reserveCapacity(90) + super.init() + } + + func startMeasurement(configuration nativeConfiguration: NativePPGConfiguration) throws { + let parsedConfiguration = try MeasurementConfiguration(native: nativeConfiguration) + let thermalState = thermalMonitor.currentState() + guard !Self.isUnsafe(thermalState) else { + throw HaptikaPPGFrameProcessorError.unsafeThermalState(thermalState.stringValue) + } + + stateLock.lock() + defer { stateLock.unlock() } + resetLocked() + + configuration = parsedConfiguration + contactDetector = PPGFingerContactDetector( + minimumConfidence: parsedConfiguration.minimumContactConfidence + ) + numericSamples.reserveCapacity( + min( + parsedConfiguration.maximumBufferedSampleCount, + Int((parsedConfiguration.targetSampleRate * parsedConfiguration.measurementSeconds).rounded(.up)) + ) + ) + startedAtMonotonicSeconds = ProcessInfo.processInfo.systemUptime + phase = .active + } + + func processFrame(frame: any HybridFrameSpec) throws -> PPGFrameUpdate? { + stateLock.lock() + defer { stateLock.unlock() } + + let thermalState = thermalMonitor.currentState() + if phase == .thermalStopped { + // Keep notifying the worklet until React Native acknowledges the terminal state and tears + // the capture session down. A single callback could be lost while the JS runtime is busy. + return makeFrameUpdate( + timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp), + thermalState: terminalThermalState ?? thermalState, + metrics: lastMetrics, + evaluation: lastContactEvaluation + ) + } + + guard phase == .active, let configuration, let contactDetector else { + // Camera teardown and Nitro calls can overlap by a frame; ignoring it is safe and avoids + // retaining an already-finished frame. + return nil + } + + if Self.isUnsafe(thermalState) { + phase = .thermalStopped + terminalThermalState = thermalState + terminalReason = "thermal_\(thermalState.stringValue)" + processedFrameCount += 1 + return makeFrameUpdate( + timestampSeconds: Self.finiteTimestamp(frame.timestamp, fallback: lastObservedTimestamp), + thermalState: thermalState, + metrics: lastMetrics, + evaluation: lastContactEvaluation + ) + } + + guard frame.isValid, + let nativeFrame = frame as? any NativeFrame, + let sampleBuffer = nativeFrame.sampleBuffer + else { + throw HaptikaPPGFrameProcessorError.frameUnavailable + } + + // The sample buffer is intentionally scoped to this call. The extractor returns value-only + // metrics and never stores the CMSampleBuffer or CVPixelBuffer. + let metrics = try metricsExtractor.extract(from: sampleBuffer) + processedFrameCount += 1 + let shouldEmit = processedFrameCount.isMultiple(of: configuration.emitEveryFrames) + + guard recordFrameTimingLocked( + timestampSeconds: metrics.timestampSeconds, + configuration: configuration + ) else { + if shouldEmit { + return makeFrameUpdate( + timestampSeconds: lastObservedTimestamp ?? metrics.timestampSeconds, + thermalState: thermalState, + metrics: lastMetrics, + evaluation: lastContactEvaluation + ) + } + return nil + } + + let evaluation = contactDetector.evaluate(metrics) + lastMetrics = metrics + lastContactEvaluation = evaluation + updateAcquisitionLocked( + metrics: metrics, + evaluation: evaluation, + configuration: configuration + ) + updateLiveEstimateLocked(configuration: configuration) + + guard shouldEmit else { return nil } + return makeFrameUpdate( + timestampSeconds: metrics.timestampSeconds, + thermalState: thermalState, + metrics: metrics, + evaluation: evaluation + ) + } + + func finishMeasurement() throws -> NativePPGProcessingResult { + let snapshot: SessionSnapshot + + stateLock.lock() + switch phase { + case .active, .thermalStopped: + guard let configuration else { + stateLock.unlock() + throw HaptikaPPGFrameProcessorError.measurementNotStarted + } + phase = .finishing + snapshot = SessionSnapshot( + configuration: configuration, + samples: numericSamples, + startedAtMonotonicSeconds: startedAtMonotonicSeconds, + endedAtMonotonicSeconds: ProcessInfo.processInfo.systemUptime, + processedFrameCount: processedFrameCount, + droppedFrameCount: droppedFrameCount, + acquisitionDroppedFrameCount: acquisitionDroppedFrameCount, + usableDurationSeconds: usableDurationSeconds, + maximumAcquisitionGapSeconds: maximumAcquisitionGapSeconds, + bufferLimitReached: bufferLimitReached, + terminalReason: terminalReason + ) + stateLock.unlock() + case .idle, .finishing: + stateLock.unlock() + throw HaptikaPPGFrameProcessorError.measurementNotStarted + } + + defer { resetAfterFinishing() } + + if let terminalReason = snapshot.terminalReason { + return makeInvalidResult( + snapshot: snapshot, + reason: terminalReason, + issue: "durationInsufficient" + ) + } + if snapshot.bufferLimitReached { + return makeInvalidResult( + snapshot: snapshot, + reason: "The numeric measurement buffer reached its safety limit.", + issue: "nonFiniteProcessingResult" + ) + } + + do { + let processor = try PPGSignalProcessor( + configuration: snapshot.configuration.coreConfiguration + ) + let analysis = try processor.analyze(samples: snapshot.samples) + return makeNativeResult(analysis: analysis, snapshot: snapshot) + } catch { + let mapped = Self.mapProcessingError(error) + return makeInvalidResult( + snapshot: snapshot, + reason: mapped.reason, + issue: mapped.issue + ) + } + } + + func cancelMeasurement() throws { + stateLock.lock() + resetLocked() + stateLock.unlock() + } + + func getThermalState() throws -> ThermalState { + thermalMonitor.currentState() + } + + private func recordFrameTimingLocked( + timestampSeconds: Double, + configuration: MeasurementConfiguration + ) -> Bool { + guard timestampSeconds.isFinite, timestampSeconds >= 0 else { + droppedFrameCount += 1 + if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 } + return false + } + guard let previousTimestamp = lastObservedTimestamp else { + lastObservedTimestamp = timestampSeconds + return true + } + + let interval = timestampSeconds - previousTimestamp + guard interval > 0 else { + droppedFrameCount += 1 + if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += 1 } + return false + } + + if acquisitionStartedAt != nil { + maximumAcquisitionGapSeconds = max(maximumAcquisitionGapSeconds, interval) + } + if recentFrameIntervals.count >= 5 { + let expectedInterval = SignalStatistics.median(recentFrameIntervals) + if expectedInterval > 0, interval > expectedInterval * 1.5 { + let estimatedMissing = min( + max((interval / expectedInterval).rounded() - 1, 0), + 100_000 + ) + let missingCount = Int(estimatedMissing) + droppedFrameCount += missingCount + if acquisitionStartedAt != nil { acquisitionDroppedFrameCount += missingCount } + } + } + + if interval <= max(configuration.maximumInterpolationGapSeconds, 0.25) { + recentFrameIntervals.append(interval) + if recentFrameIntervals.count > 90 { + recentFrameIntervals.removeFirst(recentFrameIntervals.count - 90) + } + } + lastObservedTimestamp = timestampSeconds + return true + } + + private func updateAcquisitionLocked( + metrics: PPGFrameColorMetrics, + evaluation: PPGContactEvaluation, + configuration: MeasurementConfiguration + ) { + // Acquisition is intentionally clock-based rather than contact-gated. A + // contact detector is useful feedback, but making it a hard gate can leave + // a complete minute with zero samples on cameras whose color response is + // different. The signal processor still rejects low-SNR, saturated and + // motion-corrupted portions at the end. + // Store compact numeric samples from the first camera frame. The final + // processor decides which portions are usable. Do not derive a camera + // frame count from the 100 Hz resampling target, and do not throw away a + // good signal merely because the contact heuristic is conservative. + if acquisitionStartedAt == nil { + acquisitionStartedAt = metrics.timestampSeconds + lastUsableTimestamp = nil + } + + guard acquisitionStartedAt != nil else { return } + + let sample = PPGFrameSample( + timestampSeconds: metrics.timestampSeconds, + red: metrics.redMean, + green: metrics.greenMean, + blue: metrics.blueMean, + luminance: metrics.luminanceMean, + contactConfidence: evaluation.confidence, + saturationRatio: metrics.saturationRatio, + motionArtifactScore: evaluation.motionArtifactScore + ) + totalCollectedSampleCount += 1 + if configuration.sessionPurpose == .coherence { + numericSamples.append(sample) + if numericSamples.count > configuration.maximumBufferedSampleCount { + numericSamples.removeFirst(numericSamples.count - configuration.maximumBufferedSampleCount) + } + } else if numericSamples.count < configuration.maximumBufferedSampleCount { + numericSamples.append(sample) + } else { + bufferLimitReached = true + } + + let opticallyUsable = evaluation.confidence >= 0.35 + && metrics.saturationRatio <= 0.95 + && evaluation.motionArtifactScore <= 0.90 + if opticallyUsable { + if let previousTimestamp = lastUsableTimestamp { + let interval = metrics.timestampSeconds - previousTimestamp + if interval > 0, interval <= configuration.maximumInterpolationGapSeconds { + usableDurationSeconds += interval + } + } + lastUsableTimestamp = metrics.timestampSeconds + } else { + // Never bridge quality time across a finger removal or a large movement. + lastUsableTimestamp = nil + } + } + + private func makeFrameUpdate( + timestampSeconds: Double, + thermalState: ThermalState, + metrics: PPGFrameColorMetrics?, + evaluation: PPGContactEvaluation? + ) -> PPGFrameUpdate { + let signalLevel = min(max((evaluation?.pulsatileAmplitude ?? 0) / 0.03, 0), 1) + let waveformValue: Double + if let metrics { + waveformValue = metrics.greenMean / max(metrics.redMean + metrics.greenMean + metrics.blueMean, 1) + } else { + waveformValue = 0 + } + return PPGFrameUpdate( + timestampSeconds: timestampSeconds, + contactState: evaluation?.state ?? .absent, + contactConfidence: min(max(evaluation?.confidence ?? 0, 0), 1), + signalLevel: signalLevel, + saturationRatio: min(max(metrics?.saturationRatio ?? 0, 0), 1), + motionArtifactScore: min(max(evaluation?.motionArtifactScore ?? 0, 0), 1), + collectedSampleCount: Double(totalCollectedSampleCount), + usableDurationSeconds: usableDurationSeconds, + droppedFrameCount: Double(droppedFrameCount), + thermalState: thermalState, + waveformValue: waveformValue, + liveBpm: liveBpm, + liveIpiMs: liveIpiMs, + beatSequence: Double(liveBeatSequence), + acceptedBeats: takePendingLiveBeatsLocked(), + liveWindowQuality: liveWindowQuality + ) + } + + private func takePendingLiveBeatsLocked() -> [NativeAcceptedBeat] { + let beats = pendingLiveBeats + pendingLiveBeats.removeAll(keepingCapacity: true) + return beats + } + + private func updateLiveEstimateLocked(configuration: MeasurementConfiguration) { + // Camera input is constrained to 30 fps. The final 100 Hz configuration + // is a resampling timebase and must not delay live BPM warm-up to 23 s. + let liveInputSampleRate = 30.0 + guard numericSamples.count >= Int(liveInputSampleRate * 7), + processedFrameCount - lastLiveAnalysisFrame >= 15 + else { return } + lastLiveAnalysisFrame = processedFrameCount + + let maximumWindowCount = Int(liveInputSampleRate * 15) + let window = Array(numericSamples.suffix(maximumWindowCount)) + let liveConfiguration = PPGProcessingConfiguration( + // A lightweight 30 Hz pass is enough for the live display. The final + // analysis still uses the regular 100 Hz timebase. + targetSampleRateHz: min(configuration.targetSampleRate, 30), + minimumFrequencyHz: configuration.minimumFrequencyHz, + maximumFrequencyHz: configuration.maximumFrequencyHz, + minimumAnalysisDurationSeconds: 6, + maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds, + longGapLimitSeconds: configuration.longGapLimitSeconds, + minimumContactConfidence: min(configuration.minimumContactConfidence, 0.45), + maximumSaturationRatio: 0.55, + maximumMotionArtifactScore: 0.65, + minimumPeakProminence: 0.28, + minimumPeakAmplitude: 0.08, + refractoryPeriodSeconds: 0.30, + maximumMethodDisagreementBPM: 12, + minimumQualityScore: 0.35, + minimumIntervalsForPRV: 4, + maximumRejectedIntervalRatioForPRV: 0.45, + filterTapCount: 31 + ) + + guard let analysis = try? PPGSignalProcessor(configuration: liveConfiguration) + .analyze(samples: window) + else { return } + liveWindowQuality = analysis.quality.score + + if let bpm = analysis.bpm.bpm ?? analysis.bpm.temporalBpm, + bpm.isFinite, (35...220).contains(bpm) { + liveBpm = bpm + } + let acceptedIntervals = analysis.intervals.filter(\.isAccepted) + if let interval = acceptedIntervals.last, + interval.durationMilliseconds.isFinite { + liveIpiMs = interval.durationMilliseconds + } + + // The live analysis uses an overlapping window. Count every newly seen + // accepted interval instead of incrementing only once per analysis pass; + // otherwise a 90 BPM pulse sampled every 0.5 s loses real beats. + let newlyAccepted = liveBeatTracker.ingest(acceptedIntervals) + for beat in newlyAccepted { + liveBeatSequence = beat.sequence + pendingLiveBeats.append( + NativeAcceptedBeat( + sequence: Double(beat.sequence), + timestampSeconds: beat.timestampSeconds, + intervalMs: beat.intervalMs, + confidence: beat.confidence + ) + ) + } + } + + private func makeNativeResult( + analysis: PPGAnalysisResult, + snapshot: SessionSnapshot + ) -> NativePPGProcessingResult { + var issues = analysis.quality.issues.map(Self.nativeIssue) + if analysis.bpm.rejectedPeakCount > max(3, analysis.bpm.validPeakCount / 4) { + Self.appendUnique("tooManyRejectedPeaks", to: &issues) + } + + let observedDroppedRatio = snapshot.samples.isEmpty + ? 0 + : Double(snapshot.acquisitionDroppedFrameCount) + / Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount) + let droppedFrameRatio = min( + max(max(analysis.quality.droppedFrameRatio, observedDroppedRatio), 0), + 1 + ) + if droppedFrameRatio > 0.12 { + Self.appendUnique("droppedFrames", to: &issues) + } + + let timestampJitterRatio = Self.timestampJitterRatio(analysis.timebase) + let longestGapSeconds = max( + analysis.timebase.maximumGapSeconds, + snapshot.maximumAcquisitionGapSeconds + ) + let isValid = analysis.status == .valid + let invalidReason = isValid ? nil : Self.invalidReason(for: issues) + let resampledSampleCount = max( + Int((analysis.timebase.durationSeconds * snapshot.configuration.targetSampleRate).rounded(.down)) + 1, + 0 + ) + + return NativePPGProcessingResult( + valid: isValid, + invalidReason: invalidReason, + bpm: NativeBPMEstimate( + bpm: analysis.bpm.bpm, + temporalBpm: analysis.bpm.temporalBpm, + frequencyBpm: analysis.bpm.frequencyBpm, + methodAgreementBpm: analysis.bpm.methodAgreementBpm, + validPeakCount: Double(analysis.bpm.validPeakCount), + rejectedPeakCount: Double(analysis.bpm.rejectedPeakCount), + confidence: analysis.bpm.confidence, + selectedChannel: analysis.bpm.selectedChannel.rawValue + ), + prv: NativePRVMetrics( + estimatedRmssdMs: analysis.prv.estimatedRmssdMs, + estimatedSdnnMs: analysis.prv.estimatedSdnnMs, + meanIpiMs: analysis.prv.meanIpiMs, + medianIpiMs: analysis.prv.medianIpiMs, + pnn50Percent: analysis.prv.pnn50Percent, + rangeIpiMs: analysis.prv.rangeIpiMs, + modeIpiMs: analysis.prv.modeIpiMs, + amo50Percent: analysis.prv.amo50Percent, + coefficientOfVariation: analysis.prv.coefficientOfVariation, + validIntervalCount: Double(analysis.prv.validIntervalCount), + rejectedIntervalCount: Double(analysis.prv.rejectedIntervalCount), + rejectedIntervalRatio: analysis.prv.rejectedIntervalRatio, + reliable: analysis.prv.reliable + ), + quality: NativeSignalQualityResult( + score: analysis.quality.score, + level: analysis.quality.level.rawValue, + usableDurationSeconds: analysis.quality.usableDurationSeconds, + droppedFrameRatio: droppedFrameRatio, + saturationRatio: analysis.quality.saturationRatio, + rejectedIntervalRatio: analysis.quality.rejectedIntervalRatio, + signalToNoiseRatio: analysis.quality.signalToNoiseRatio, + motionArtifactRatio: analysis.quality.motionArtifactRatio, + methodAgreementScore: analysis.quality.methodAgreementScore, + timestampJitterRatio: timestampJitterRatio, + longestGapSeconds: longestGapSeconds, + issues: issues + ), + startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds, + endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds, + rawSampleCount: Double(snapshot.samples.count), + resampledSampleCount: Double(resampledSampleCount) + ) + } + + private func makeInvalidResult( + snapshot: SessionSnapshot, + reason: String, + issue: String + ) -> NativePPGProcessingResult { + let intervals = zip(snapshot.samples, snapshot.samples.dropFirst()).compactMap { pair -> Double? in + let interval = pair.1.timestampSeconds - pair.0.timestampSeconds + return interval.isFinite && interval > 0 ? interval : nil + } + let estimatedRate = intervals.isEmpty ? 0 : 1 / max(SignalStatistics.median(intervals), 1e-9) + let intervalMAD = intervals.isEmpty ? 0 : SignalStatistics.mad(intervals) + let jitterRatio = estimatedRate > 0 ? intervalMAD * estimatedRate : 0 + let saturation = snapshot.samples.isEmpty + ? 0 + : SignalStatistics.mean(snapshot.samples.map(\.saturationRatio)) + let motion = snapshot.samples.isEmpty + ? 0 + : SignalStatistics.mean(snapshot.samples.map(\.motionArtifactScore)) + let droppedRatio = snapshot.samples.isEmpty + ? 0 + : Double(snapshot.acquisitionDroppedFrameCount) + / Double(snapshot.samples.count + snapshot.acquisitionDroppedFrameCount) + + return NativePPGProcessingResult( + valid: false, + invalidReason: reason, + bpm: NativeBPMEstimate( + bpm: nil, + temporalBpm: nil, + frequencyBpm: nil, + methodAgreementBpm: nil, + validPeakCount: 0, + rejectedPeakCount: 0, + confidence: 0, + selectedChannel: "none" + ), + prv: NativePRVMetrics( + estimatedRmssdMs: nil, + estimatedSdnnMs: nil, + meanIpiMs: nil, + medianIpiMs: nil, + pnn50Percent: nil, + rangeIpiMs: nil, + modeIpiMs: nil, + amo50Percent: nil, + coefficientOfVariation: nil, + validIntervalCount: 0, + rejectedIntervalCount: 0, + rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0, + reliable: false + ), + quality: NativeSignalQualityResult( + score: 0, + level: "invalid", + usableDurationSeconds: snapshot.usableDurationSeconds, + droppedFrameRatio: min(max(droppedRatio, 0), 1), + saturationRatio: min(max(saturation, 0), 1), + rejectedIntervalRatio: snapshot.samples.isEmpty ? 1 : 0, + signalToNoiseRatio: nil, + motionArtifactRatio: min(max(motion, 0), 1), + methodAgreementScore: 0, + timestampJitterRatio: max(jitterRatio, 0), + longestGapSeconds: snapshot.maximumAcquisitionGapSeconds, + issues: [issue] + ), + startedAtMonotonicSeconds: snapshot.startedAtMonotonicSeconds, + endedAtMonotonicSeconds: snapshot.endedAtMonotonicSeconds, + rawSampleCount: Double(snapshot.samples.count), + resampledSampleCount: 0 + ) + } + + private func resetAfterFinishing() { + stateLock.lock() + resetLocked() + stateLock.unlock() + } + + private func resetLocked() { + contactDetector?.reset() + contactDetector = nil + configuration = nil + terminalThermalState = nil + numericSamples.removeAll(keepingCapacity: false) + phase = .idle + startedAtMonotonicSeconds = 0 + terminalReason = nil + bufferLimitReached = false + + processedFrameCount = 0 + droppedFrameCount = 0 + acquisitionDroppedFrameCount = 0 + lastObservedTimestamp = nil + recentFrameIntervals.removeAll(keepingCapacity: true) + maximumAcquisitionGapSeconds = 0 + + validContactStartedAt = nil + acquisitionStartedAt = nil + lastUsableTimestamp = nil + usableDurationSeconds = 0 + + lastMetrics = nil + lastContactEvaluation = nil + liveBpm = nil + liveIpiMs = nil + liveBeatSequence = 0 + liveBeatTracker.reset() + lastLiveAnalysisFrame = 0 + pendingLiveBeats.removeAll(keepingCapacity: false) + liveWindowQuality = nil + totalCollectedSampleCount = 0 + } + + private static func isUnsafe(_ state: ThermalState) -> Bool { + PPGThermalSafetyPolicy.shouldStopMeasurement(forStateName: state.stringValue) + } + + private static func finiteTimestamp(_ timestamp: Double, fallback: Double?) -> Double { + if timestamp.isFinite, timestamp >= 0 { return timestamp } + return fallback ?? 0 + } + + private static func timestampJitterRatio(_ diagnostics: TimebaseDiagnostics) -> Double { + guard diagnostics.estimatedSampleRateHz > 0 else { return 0 } + return max(diagnostics.frameIntervalMADSeconds * diagnostics.estimatedSampleRateHz, 0) + } + + private static func nativeIssue(_ issue: SignalQualityIssue) -> String { + switch issue { + case .insufficientDuration: + return "durationInsufficient" + case .invalidTimestamps, .irregularTimestamps: + return "timestampJitter" + case .longInterruption: + return "longGap" + case .tooManyDroppedFrames: + return "droppedFrames" + case .poorFingerContact: + return "contactInsufficient" + case .excessiveSaturation: + return "saturation" + case .motionArtifacts: + return "motionArtifacts" + case .lowPulsatileAmplitude: + return "lowPulsatileAmplitude" + case .lowSignalToNoise: + return "lowSignalToNoiseRatio" + case .tooFewPeaks: + return "tooFewPeaks" + case .inconsistentBPMMethods: + return "methodDisagreement" + case .tooManyRejectedIntervals: + return "tooManyRejectedIntervals" + case .unstableChannel: + return "channelInstability" + case .nonFiniteInput: + return "nonFiniteProcessingResult" + } + } + + private static func appendUnique(_ issue: String, to issues: inout [String]) { + if !issues.contains(issue) { issues.append(issue) } + } + + private static func invalidReason(for issues: [String]) -> String { + guard let issue = issues.first else { + return "La qualité du signal est insuffisante." + } + switch issue { + case "contactInsufficient", "contactInterrupted": + return "Le contact du doigt a été insuffisant ou interrompu." + case "durationInsufficient": + return "La durée de signal exploitable est insuffisante." + case "droppedFrames", "timestampJitter", "longGap": + return "La capture comporte trop d’interruptions ou d’images manquantes." + case "saturation": + return "Le signal caméra est trop saturé." + case "motionArtifacts": + return "Trop de mouvements ont perturbé la mesure." + case "lowPulsatileAmplitude", "lowSignalToNoiseRatio": + return "Le signal pulsatile est trop faible ou trop bruité." + case "tooFewPeaks", "tooManyRejectedPeaks", "tooManyRejectedIntervals": + return "Trop peu de pulsations fiables ont été détectées." + case "methodDisagreement": + return "Les estimations temporelle et fréquentielle ne concordent pas." + case "channelInstability": + return "Aucun canal couleur n’est suffisamment stable." + default: + return "Le traitement numérique du signal a produit un résultat invalide." + } + } + + private static func mapProcessingError(_ error: Error) -> (reason: String, issue: String) { + guard let signalError = error as? PPGSignalError else { + return ("Le traitement numérique du signal a échoué.", "nonFiniteProcessingResult") + } + switch signalError { + case .insufficientSamples, .insufficientDuration: + return ("La durée de signal exploitable est insuffisante.", "durationInsufficient") + case .invalidTimestamps: + return ("Les horodatages caméra sont invalides.", "timestampJitter") + case .noUsableChannel: + return ("Aucun canal couleur n’est exploitable.", "channelInstability") + case .invalidConfiguration: + return ("La configuration du traitement du signal est invalide.", "nonFiniteProcessingResult") + } + } +} + +private enum HaptikaPPGFrameProcessorError: LocalizedError { + case invalidConfiguration(String) + case unsafeThermalState(String) + case frameUnavailable + case measurementNotStarted + + var errorDescription: String? { + switch self { + case .invalidConfiguration(let reason): + return "Invalid PPG configuration: \(reason)." + case .unsafeThermalState(let state): + return "PPG measurement refused because the iPhone thermal state is \(state)." + case .frameUnavailable: + return "The VisionCamera frame is invalid or does not expose a CMSampleBuffer." + case .measurementNotStarted: + return "No PPG measurement is currently active." + } + } +} diff --git a/packages/react-native-haptika-ppg/ios/FrameProcessor/PPGFingerContactDetector.swift b/packages/react-native-haptika-ppg/ios/FrameProcessor/PPGFingerContactDetector.swift new file mode 100644 index 0000000..a21532c --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/FrameProcessor/PPGFingerContactDetector.swift @@ -0,0 +1,132 @@ +import Foundation + +struct PPGContactEvaluation: Sendable { + let state: FingerContactState + let confidence: Double + let pulsatileAmplitude: Double + let motionArtifactScore: Double +} + +/// Camera-agnostic coverage detector. +/// +/// Contact is deliberately not a prerequisite for collecting frames. This +/// class only coaches the user and supplies soft quality metadata. The final +/// decision is made from the periodic optical signal, never from skin colour. +final class PPGFingerContactDetector { + private let requiredStableFrames = 6 + private let requiredReleaseFrames = 12 + private let historyLimit = 90 + private var history: [PPGFrameColorMetrics] = [] + private var state: FingerContactState = .adjusting + private var stableFrames = 0 + private var releaseFrames = 0 + + init(minimumConfidence _: Double) { + history.reserveCapacity(historyLimit) + } + + func reset() { + history.removeAll(keepingCapacity: true) + state = .adjusting + stableFrames = 0 + releaseFrames = 0 + } + + func evaluate(_ metrics: PPGFrameColorMetrics) -> PPGContactEvaluation { + let previous = history.last + history.append(metrics) + if history.count > historyLimit { + history.removeFirst(history.count - historyLimit) + } + + let brightness = metrics.luminanceMean / 255 + let red = metrics.redMean / 255 + let green = metrics.greenMean / 255 + let blue = metrics.blueMean / 255 + let chroma = max(red, green, blue) - min(red, green, blue) + let spatialDeviation = sqrt(max(metrics.redVariance + metrics.greenVariance, 0) / 2) + let uniformity = clamp01(1 - spatialDeviation / 82) + let exposure = smoothWindow(brightness, lower: 0.025, upper: 0.985, feather: 0.10) + let warmth = clamp01(0.5 + (red - blue) * 1.4) + let coverage = clamp01(0.45 * uniformity + 0.30 * exposure + 0.15 * warmth + 0.10 * min(chroma * 3, 1)) + let motion = motionScore(current: metrics, previous: previous) + let amplitude = pulsatileAmplitude() + let temporalStability = clamp01(1 - motion / 0.55) + let confidence = clamp01(0.72 * coverage + 0.28 * temporalStability) + + let candidate: FingerContactState + if brightness < 0.015 || (uniformity < 0.12 && warmth < 0.25) { + candidate = .absent + } else if brightness > 0.992 || metrics.saturationRatio > 0.92 { + candidate = .overexposed + } else if history.count >= 15 && motion > 0.68 { + candidate = .unstable + } else if confidence >= 0.48 { + candidate = .valid + } else { + candidate = .adjusting + } + + applyHysteresis(candidate) + return PPGContactEvaluation( + state: state, + confidence: confidence, + pulsatileAmplitude: amplitude, + motionArtifactScore: motion + ) + } + + private func applyHysteresis(_ candidate: FingerContactState) { + if candidate == .valid { + stableFrames += 1 + releaseFrames = 0 + if stableFrames >= requiredStableFrames { state = .valid } + return + } + + stableFrames = 0 + releaseFrames += 1 + // Brief changes are feedback, not session resets. Holding the last valid + // state avoids a flashing UI while automatic exposure settles. + if state == .valid && releaseFrames < requiredReleaseFrames { return } + state = candidate + } + + private func pulsatileAmplitude() -> Double { + let window = history.suffix(60) + guard window.count >= 20 else { return 0 } + let normalized = window.map { + $0.greenMean / max($0.redMean + $0.greenMean + $0.blueMean, 1) + } + return max((normalized.max() ?? 0) - (normalized.min() ?? 0), 0) + } + + private func motionScore( + current: PPGFrameColorMetrics, + previous: PPGFrameColorMetrics? + ) -> Double { + guard let previous else { return 0 } + let lightDelta = abs(current.luminanceMean - previous.luminanceMean) / 255 + let redDelta = abs(current.redMean - previous.redMean) / 255 + let greenDelta = abs(current.greenMean - previous.greenMean) / 255 + // Normal pulse modulation is small. Only large frame-to-frame steps are + // considered motion, regardless of the absolute colour of the fingertip. + return clamp01(max(lightDelta / 0.16, (redDelta + greenDelta) / 0.28)) + } + + private func smoothWindow( + _ value: Double, + lower: Double, + upper: Double, + feather: Double + ) -> Double { + if value <= lower || value >= upper { return 0 } + if value < lower + feather { return clamp01((value - lower) / feather) } + if value > upper - feather { return clamp01((upper - value) / feather) } + return 1 + } + + private func clamp01(_ value: Double) -> Double { + min(max(value.isFinite ? value : 0, 0), 1) + } +} diff --git a/packages/react-native-haptika-ppg/ios/FrameProcessor/PPGFrameMetricsExtractor.swift b/packages/react-native-haptika-ppg/ios/FrameProcessor/PPGFrameMetricsExtractor.swift new file mode 100644 index 0000000..65a2c58 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/FrameProcessor/PPGFrameMetricsExtractor.swift @@ -0,0 +1,237 @@ +import CoreMedia +import CoreVideo +import Foundation + +struct PPGFrameColorMetrics: Sendable { + let timestampSeconds: Double + let redMean: Double + let greenMean: Double + let blueMean: Double + let redVariance: Double + let greenVariance: Double + let luminanceMean: Double + let saturationRatio: Double +} + +enum PPGFrameMetricsExtractorError: Error { + case invalidTimestamp + case unsupportedPixelFormat(OSType) + case pixelBufferLockFailed(CVReturn) + case missingPlane + case missingBaseAddress + case emptyRegionOfInterest +} + +/// Extracts compact color statistics from a central ROI without retaining or copying a frame. +final class PPGFrameMetricsExtractor { + private let gridSize: Int + private let roiFraction: Double + + init(gridSize: Int = 32, roiFraction: Double = 0.55) { + self.gridSize = max(8, gridSize) + self.roiFraction = min(max(roiFraction, 0.2), 0.9) + } + + func extract(from sampleBuffer: CMSampleBuffer) throws -> PPGFrameColorMetrics { + let timestamp = CMTimeGetSeconds(CMSampleBufferGetPresentationTimeStamp(sampleBuffer)) + guard timestamp.isFinite, timestamp >= 0 else { + throw PPGFrameMetricsExtractorError.invalidTimestamp + } + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { + throw PPGFrameMetricsExtractorError.missingBaseAddress + } + + let lockFlags = CVPixelBufferLockFlags.readOnly + let lockStatus = CVPixelBufferLockBaseAddress(pixelBuffer, lockFlags) + guard lockStatus == kCVReturnSuccess else { + throw PPGFrameMetricsExtractorError.pixelBufferLockFailed(lockStatus) + } + defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, lockFlags) } + + let pixelFormat = CVPixelBufferGetPixelFormatType(pixelBuffer) + switch pixelFormat { + case kCVPixelFormatType_420YpCbCr8BiPlanarFullRange: + return try extractBiPlanarYUV( + from: pixelBuffer, + timestamp: timestamp, + isFullRange: true + ) + case kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange: + return try extractBiPlanarYUV( + from: pixelBuffer, + timestamp: timestamp, + isFullRange: false + ) + case kCVPixelFormatType_32BGRA: + return try extractBGRA(from: pixelBuffer, timestamp: timestamp) + default: + throw PPGFrameMetricsExtractorError.unsupportedPixelFormat(pixelFormat) + } + } + + private func extractBiPlanarYUV( + from pixelBuffer: CVPixelBuffer, + timestamp: Double, + isFullRange: Bool + ) throws -> PPGFrameColorMetrics { + guard CVPixelBufferGetPlaneCount(pixelBuffer) >= 2 else { + throw PPGFrameMetricsExtractorError.missingPlane + } + guard let yBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0), + let uvBase = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) + else { + throw PPGFrameMetricsExtractorError.missingBaseAddress + } + + let width = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0) + let height = CVPixelBufferGetHeightOfPlane(pixelBuffer, 0) + let yStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0) + let uvStride = CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1) + let yBytes = yBase.assumingMemoryBound(to: UInt8.self) + let uvBytes = uvBase.assumingMemoryBound(to: UInt8.self) + let roi = regionOfInterest(width: width, height: height) + let stepX = max(roi.width / gridSize, 1) + let stepY = max(roi.height / gridSize, 1) + var accumulator = ColorAccumulator() + + for y in stride(from: roi.minY, to: roi.maxY, by: stepY) { + for x in stride(from: roi.minX, to: roi.maxX, by: stepX) { + let yValue = Double(yBytes[y * yStride + x]) + let uvOffset = (y / 2) * uvStride + (x / 2) * 2 + let cb = Double(uvBytes[uvOffset]) + let cr = Double(uvBytes[uvOffset + 1]) + let rgb = convertYCbCrToRGB(y: yValue, cb: cb, cr: cr, isFullRange: isFullRange) + accumulator.add(red: rgb.red, green: rgb.green, blue: rgb.blue, luminance: yValue) + } + } + + return try accumulator.result(timestamp: timestamp) + } + + private func extractBGRA( + from pixelBuffer: CVPixelBuffer, + timestamp: Double + ) throws -> PPGFrameColorMetrics { + guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { + throw PPGFrameMetricsExtractorError.missingBaseAddress + } + let width = CVPixelBufferGetWidth(pixelBuffer) + let height = CVPixelBufferGetHeight(pixelBuffer) + let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) + let bytes = baseAddress.assumingMemoryBound(to: UInt8.self) + let roi = regionOfInterest(width: width, height: height) + let stepX = max(roi.width / gridSize, 1) + let stepY = max(roi.height / gridSize, 1) + var accumulator = ColorAccumulator() + + for y in stride(from: roi.minY, to: roi.maxY, by: stepY) { + for x in stride(from: roi.minX, to: roi.maxX, by: stepX) { + let offset = y * bytesPerRow + x * 4 + let blue = Double(bytes[offset]) + let green = Double(bytes[offset + 1]) + let red = Double(bytes[offset + 2]) + let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue + accumulator.add(red: red, green: green, blue: blue, luminance: luminance) + } + } + + return try accumulator.result(timestamp: timestamp) + } + + private func regionOfInterest(width: Int, height: Int) -> ( + minX: Int, + maxX: Int, + minY: Int, + maxY: Int, + width: Int, + height: Int + ) { + let roiWidth = max(Int(Double(width) * roiFraction), 1) + let roiHeight = max(Int(Double(height) * roiFraction), 1) + let minX = max((width - roiWidth) / 2, 0) + let minY = max((height - roiHeight) / 2, 0) + return (minX, minX + roiWidth, minY, minY + roiHeight, roiWidth, roiHeight) + } + + private func convertYCbCrToRGB( + y: Double, + cb: Double, + cr: Double, + isFullRange: Bool + ) -> (red: Double, green: Double, blue: Double) { + let red: Double + let green: Double + let blue: Double + + if isFullRange { + let shiftedCb = cb - 128 + let shiftedCr = cr - 128 + red = y + 1.402 * shiftedCr + green = y - 0.344_136 * shiftedCb - 0.714_136 * shiftedCr + blue = y + 1.772 * shiftedCb + } else { + let shiftedY = max(y - 16, 0) + let shiftedCb = cb - 128 + let shiftedCr = cr - 128 + red = 1.164_383 * shiftedY + 1.596_027 * shiftedCr + green = 1.164_383 * shiftedY - 0.391_762 * shiftedCb - 0.812_968 * shiftedCr + blue = 1.164_383 * shiftedY + 2.017_232 * shiftedCb + } + + return (clampByte(red), clampByte(green), clampByte(blue)) + } + + private func clampByte(_ value: Double) -> Double { + min(max(value, 0), 255) + } +} + +private struct ColorAccumulator { + private(set) var count = 0 + private var redMean = 0.0 + private var greenMean = 0.0 + private var blueMean = 0.0 + private var luminanceMean = 0.0 + private var redM2 = 0.0 + private var greenM2 = 0.0 + private var saturatedCount = 0 + + mutating func add(red: Double, green: Double, blue: Double, luminance: Double) { + count += 1 + let n = Double(count) + + let redDelta = red - redMean + redMean += redDelta / n + redM2 += redDelta * (red - redMean) + + let greenDelta = green - greenMean + greenMean += greenDelta / n + greenM2 += greenDelta * (green - greenMean) + + blueMean += (blue - blueMean) / n + luminanceMean += (luminance - luminanceMean) / n + // A fingertip lit by the torch commonly clips only the red channel while + // green still contains a clean pulsatile waveform. Count true optical + // clipping only when both useful color channels (or luminance) are clipped. + if (red >= 250 && green >= 250) || luminance >= 250 { + saturatedCount += 1 + } + } + + func result(timestamp: Double) throws -> PPGFrameColorMetrics { + guard count > 0 else { + throw PPGFrameMetricsExtractorError.emptyRegionOfInterest + } + let divisor = Double(max(count - 1, 1)) + return PPGFrameColorMetrics( + timestampSeconds: timestamp, + redMean: redMean, + greenMean: greenMean, + blueMean: blueMean, + redVariance: redM2 / divisor, + greenVariance: greenM2 / divisor, + luminanceMean: luminanceMean, + saturationRatio: Double(saturatedCount) / Double(count) + ) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Haptics/HybridHaptikaCardiacMirror.swift b/packages/react-native-haptika-ppg/ios/Haptics/HybridHaptikaCardiacMirror.swift new file mode 100644 index 0000000..f77f7c2 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Haptics/HybridHaptikaCardiacMirror.swift @@ -0,0 +1,303 @@ +import CoreHaptics +import Foundation +import NitroModules + +/// HAPTIKA.CARDIAC_MIRROR_BEAT v1.0.0 +/// +/// This pattern is deliberately isolated from generic notification haptics. A +/// short transient with a controlled sharpness gives the user a repeatable +/// “heartbeat” texture without pretending to reproduce a clinical waveform. +final class HybridHaptikaCardiacMirror: HybridHaptikaCardiacMirrorSpec { + private static let patternID = "HAPTIKA.CARDIAC_MIRROR_BEAT" + private static let patternVersion = "1.0.0" + // Kept at zero for Nitro ABI compatibility. Public Watch workout APIs expose + // cadence updates, not exact beat events, so Haptika advertises no latency + // SLA between a biological beat and the reconstructed iPhone pulse. + private static let latencyBudgetMs = 0.0 + + private let lock = NSLock() + private var engine: CHHapticEngine? + private var patternPlayer: CHHapticPatternPlayer? + private var regulationPlayer: CHHapticAdvancedPatternPlayer? + private var supported: Bool + + override init() { + self.supported = CHHapticEngine.capabilitiesForHardware().supportsHaptics + super.init() + guard supported else { return } + configureEngine() + } + + func isSupported() throws -> Bool { + return supported + } + + func patternInfo() throws -> NativeCardiacMirrorPatternInfo { + return NativeCardiacMirrorPatternInfo( + id: Self.patternID, + version: Self.patternVersion, + latencyBudgetMs: Self.latencyBudgetMs + ) + } + + func start(targetBpm: Double) throws { + guard supported else { return } + lock.lock() + defer { lock.unlock() } + do { + try engine?.start() + if patternPlayer == nil { + patternPlayer = try makePatternPlayer() + } + } catch { + // Haptics are optional and must never block the visual experience. + patternPlayer = nil + } + } + + func pulse() throws { + guard supported else { return } + lock.lock() + defer { lock.unlock() } + do { + try engine?.start() + if patternPlayer == nil { + patternPlayer = try makePatternPlayer() + } + try patternPlayer?.start(atTime: CHHapticTimeImmediate) + } catch { + patternPlayer = nil + } + } + + func stop() throws { + lock.lock() + defer { lock.unlock() } + patternPlayer = nil + regulationPlayer = nil + engine?.stop(completionHandler: nil) + } + + func playRegulationProtocol(kind: String) throws -> Double { + guard supported else { return 0 } + lock.lock() + defer { lock.unlock() } + + do { + try engine?.start() + try regulationPlayer?.stop(atTime: CHHapticTimeImmediate) + + let configuration = try makeRegulationPattern(kind: kind) + guard let engine else { return 0 } + let player = try engine.makeAdvancedPlayer(with: configuration.pattern) + player.loopEnabled = configuration.loops + regulationPlayer = player + try player.start(atTime: CHHapticTimeImmediate) + return configuration.durationSeconds + } catch { + regulationPlayer = nil + return 0 + } + } + + func stopRegulationProtocol() throws { + lock.lock() + defer { lock.unlock() } + try? regulationPlayer?.stop(atTime: CHHapticTimeImmediate) + regulationPlayer = nil + } + + func startWatchSession() throws { + HaptikaWatchConnectivityService.shared.activate() + } + + func watchReachable() throws -> Bool { + return HaptikaWatchConnectivityService.shared.state().reachable + } + + func latestHeartbeat() throws -> NativeCardiacMirrorHeartbeat? { + guard let sample = HaptikaWatchConnectivityService.shared.latestHeartRate() else { + return nil + } + return NativeCardiacMirrorHeartbeat( + timestampSeconds: sample.timestampSeconds, + bpm: sample.bpm + ) + } + + private func configureEngine() { + do { + let hapticEngine = try CHHapticEngine() + hapticEngine.resetHandler = { [weak self] in + self?.lock.lock() + defer { self?.lock.unlock() } + self?.patternPlayer = nil + } + hapticEngine.stoppedHandler = { [weak self] _ in + self?.lock.lock() + defer { self?.lock.unlock() } + self?.patternPlayer = nil + } + engine = hapticEngine + } catch { + supported = false + } + } + + private func makePatternPlayer() throws -> CHHapticPatternPlayer { + guard let engine else { + throw NSError(domain: "HaptikaCardiacMirror", code: 1) + } + let intensity = CHHapticEventParameter( + parameterID: .hapticIntensity, + value: 0.78 + ) + let sharpness = CHHapticEventParameter( + parameterID: .hapticSharpness, + value: 0.32 + ) + let event = CHHapticEvent( + eventType: .hapticTransient, + parameters: [intensity, sharpness], + relativeTime: 0 + ) + let pattern = try CHHapticPattern(events: [event], parameters: []) + return try engine.makePlayer(with: pattern) + } + + private func makeRegulationPattern( + kind: String + ) throws -> (pattern: CHHapticPattern, durationSeconds: Double, loops: Bool) { + switch kind { + case "coherence55": + return (try makeBreathingPattern(inhale: 5, exhale: 5), 10, true) + case "relax46": + return (try makeBreathingPattern(inhale: 4, exhale: 6), 10, true) + case "box4444": + return (try makePhasedBreathingPattern(inhale: 4, hold: 4, exhale: 4, emptyHold: 4), 16, true) + case "breathing478": + return (try makePhasedBreathingPattern(inhale: 4, hold: 7, exhale: 8, emptyHold: 0), 19, true) + case "sosStress": + return (try makeSettlingPattern(intensity: 0.25, duration: 60), 62.2, false) + case "microAnchor": + return (try makeSettlingPattern(intensity: 0.15, duration: 35), 37, false) + default: + throw NSError(domain: "HaptikaRegulation", code: 2) + } + } + + private func makeBreathingPattern( + inhale: TimeInterval, + exhale: TimeInterval + ) throws -> CHHapticPattern { + let inhaleEvent = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.55), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08) + ], + relativeTime: 0, + duration: inhale + ) + let transition = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.65) + ], + relativeTime: inhale + ) + let exhaleEvent = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.24), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04) + ], + relativeTime: inhale + 0.08, + duration: exhale - 0.08 + ) + return try CHHapticPattern( + events: [inhaleEvent, transition, exhaleEvent], + parameters: [] + ) + } + + private func makePhasedBreathingPattern( + inhale: TimeInterval, + hold: TimeInterval, + exhale: TimeInterval, + emptyHold: TimeInterval + ) throws -> CHHapticPattern { + let inhaleEvent = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.48), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.08) + ], + relativeTime: 0, + duration: inhale + ) + let fullHoldMarker = CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.42), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.52) + ], + relativeTime: inhale + ) + let exhaleEvent = CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.20), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.03) + ], + relativeTime: inhale + hold, + duration: exhale + ) + var events = [inhaleEvent, fullHoldMarker, exhaleEvent] + if emptyHold > 0 { + events.append( + CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.25), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.25) + ], + relativeTime: inhale + hold + exhale + emptyHold - 0.02 + ) + ) + } + return try CHHapticPattern(events: events, parameters: []) + } + + private func makeSettlingPattern( + intensity: Float, + duration: TimeInterval + ) throws -> CHHapticPattern { + var events: [CHHapticEvent] = [] + for index in 0..<3 { + events.append( + CHHapticEvent( + eventType: .hapticTransient, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.62), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.42) + ], + relativeTime: TimeInterval(index) * 0.6 + ) + ) + } + events.append( + CHHapticEvent( + eventType: .hapticContinuous, + parameters: [ + CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity), + CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.04) + ], + relativeTime: 2, + duration: duration + ) + ) + return try CHHapticPattern(events: events, parameters: []) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Health/HybridHaptikaHealthKit.swift b/packages/react-native-haptika-ppg/ios/Health/HybridHaptikaHealthKit.swift new file mode 100644 index 0000000..d7c0ab4 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Health/HybridHaptikaHealthKit.swift @@ -0,0 +1,451 @@ +import Foundation +import HealthKit +import NitroModules + +/// Read-only HealthKit access used as dated physiological context. +/// +/// This module deliberately uses sample queries rather than observer/live queries. It never +/// creates heartbeat series and never treats historical HealthKit values as a current reading. +final class HybridHaptikaHealthKit: HybridHaptikaHealthKitSpec { + private static let defaultLookbackDays = 30.0 + private static let minimumLookbackDays = 1.0 + private static let maximumLookbackDays = 365.0 + private static let maximumRecentHeartRateSamples = 100 + + private let healthStore: HKHealthStore + + override init() { + self.healthStore = HKHealthStore() + super.init() + } + + func isAvailable() throws -> Bool { + return HKHealthStore.isHealthDataAvailable() + } + + func requestReadAuthorization() throws -> Promise { + guard HKHealthStore.isHealthDataAvailable() else { + return Promise.resolved(withResult: false) + } + + let healthStore = self.healthStore + let readTypes = Self.readTypes() + + return Promise.async { + do { + try await healthStore.requestAuthorization( + toShare: Set(), + read: readTypes + ) + + // HealthKit intentionally does not reveal whether read access was granted. A successful + // authorization request only means that the sheet completed without an operational error. + return true + } catch { + // HealthKit is optional; cancellation, a missing entitlement, or a locked/unavailable + // store must not block the camera measurement flow. + return false + } + } + } + + func fetchContext(lookbackDays: Double) throws -> Promise { + let fetchedAt = Date() + + guard HKHealthStore.isHealthDataAvailable() else { + return Promise.resolved( + withResult: Self.emptyContext( + available: false, + authorizationRequestSettled: false, + fetchedAt: fetchedAt + ) + ) + } + + let healthStore = self.healthStore + let boundedLookbackDays = Self.boundedLookbackDays(lookbackDays) + + return Promise.async { + let authorizationRequestSettled = await Self.authorizationRequestIsSettled( + healthStore: healthStore + ) + + // Do not query before the optional permission flow has been presented. When access has been + // denied, HealthKit protects the decision by returning no samples, just as it does when the + // store genuinely has no samples. + guard authorizationRequestSettled else { + return Self.emptyContext( + available: true, + authorizationRequestSettled: false, + fetchedAt: fetchedAt + ) + } + + let startDate = fetchedAt.addingTimeInterval(-boundedLookbackDays * 86_400.0) + + async let restingSamples = Self.quantitySamplesSafely( + healthStore: healthStore, + type: Self.quantityType(.restingHeartRate), + startDate: startDate, + endDate: fetchedAt, + limit: 1 + ) + async let hrvSamples = Self.quantitySamplesSafely( + healthStore: healthStore, + type: Self.quantityType(.heartRateVariabilitySDNN), + startDate: startDate, + endDate: fetchedAt, + limit: 1 + ) + async let heartRateSamples = Self.quantitySamplesSafely( + healthStore: healthStore, + type: Self.quantityType(.heartRate), + startDate: startDate, + endDate: fetchedAt, + limit: Self.maximumRecentHeartRateSamples + ) + + let (resting, hrv, heartRates) = await ( + restingSamples, + hrvSamples, + heartRateSamples + ) + + let latestResting = resting.first + let latestHrv = hrv.first + + let nativeResting = latestResting.flatMap { + Self.makeNativeQuantitySample( + $0, + metricType: .restingheartrate, + unit: Self.beatsPerMinuteUnit, + unitLabel: "count/min" + ) + } + let nativeHrv = latestHrv.flatMap { + Self.makeNativeQuantitySample( + $0, + metricType: .hrvsdnn, + unit: Self.millisecondUnit, + unitLabel: "ms" + ) + } + let nativeHeartRates = heartRates.compactMap { + Self.makeNativeQuantitySample( + $0, + metricType: .heartrate, + unit: Self.beatsPerMinuteUnit, + unitLabel: "count/min" + ) + } + + return NativeHealthKitContext( + available: true, + // This means the authorization request no longer needs presenting. Apple deliberately + // makes an explicit read denial indistinguishable from an authorized store with no data. + authorized: authorizationRequestSettled, + latestRestingHeartRate: nativeResting, + latestHrvSdnn: nativeHrv, + recentHeartRateSamples: nativeHeartRates, + contextConfidence: Self.contextConfidence( + latestResting: latestResting, + latestHrv: latestHrv, + heartRates: heartRates, + fetchedAt: fetchedAt, + lookbackDays: boundedLookbackDays + ), + fetchedAt: Self.iso8601String(from: fetchedAt) + ) + } + } + + func fetchLatestHeartbeatSeries() throws -> Promise { + guard HKHealthStore.isHealthDataAvailable() else { + return Promise.resolved(withResult: nil) + } + + let healthStore = self.healthStore + + return Promise.async { + guard await Self.authorizationRequestIsSettled(healthStore: healthStore) else { + return nil + } + + do { + guard let sample = try await Self.latestHeartbeatSeriesSample( + healthStore: healthStore + ) else { + return nil + } + + var heartbeatTimes: [Double] = [] + var precededByGapIndexes: [Double] = [] + let descriptor = HKHeartbeatSeriesQueryDescriptor(sample) + + for try await heartbeat in descriptor.results(for: healthStore) { + let time = heartbeat.timeIntervalSinceStart + guard time.isFinite, time >= 0 else { + continue + } + + heartbeatTimes.append(time) + if heartbeat.precededByGap { + precededByGapIndexes.append(Double(heartbeatTimes.count - 1)) + } + } + + guard !heartbeatTimes.isEmpty else { + return nil + } + + let source = sample.sourceRevision.source + return NativeHeartbeatSeriesSummary( + startDate: Self.iso8601String(from: sample.startDate), + endDate: Self.iso8601String(from: sample.endDate), + sourceName: Self.nonEmpty(source.name), + sourceBundleIdentifier: Self.nonEmpty(source.bundleIdentifier), + heartbeatTimesSinceSeriesStartSeconds: heartbeatTimes, + precededByGapIndexes: precededByGapIndexes + ) + } catch { + // A read denial, no accessible series, protected-data lock, or query failure all degrade to + // an absent optional context. None of them should affect the camera PPG measurement. + return nil + } + } + } + + private static var beatsPerMinuteUnit: HKUnit { + return HKUnit.count().unitDivided(by: .minute()) + } + + private static var millisecondUnit: HKUnit { + return HKUnit.secondUnit(with: .milli) + } + + private static func quantityType( + _ identifier: HKQuantityTypeIdentifier + ) -> HKQuantityType? { + return HKObjectType.quantityType(forIdentifier: identifier) + } + + private static func readTypes() -> Set { + var types = Set() + + if let heartRate = quantityType(.heartRate) { + types.insert(heartRate) + } + if let restingHeartRate = quantityType(.restingHeartRate) { + types.insert(restingHeartRate) + } + if let hrvSdnn = quantityType(.heartRateVariabilitySDNN) { + types.insert(hrvSdnn) + } + + // Read-only access to series already present in HealthKit. No builder or write type is used. + types.insert(HKSeriesType.heartbeat()) + return types + } + + private static func authorizationRequestIsSettled( + healthStore: HKHealthStore + ) async -> Bool { + do { + let status = try await healthStore.statusForAuthorizationRequest( + toShare: Set(), + read: readTypes() + ) + return status == .unnecessary + } catch { + return false + } + } + + private static func quantitySamplesSafely( + healthStore: HKHealthStore, + type: HKQuantityType?, + startDate: Date, + endDate: Date, + limit: Int + ) async -> [HKQuantitySample] { + guard let type else { + return [] + } + + do { + return try await quantitySamples( + healthStore: healthStore, + type: type, + startDate: startDate, + endDate: endDate, + limit: limit + ) + } catch { + return [] + } + } + + private static func quantitySamples( + healthStore: HKHealthStore, + type: HKQuantityType, + startDate: Date, + endDate: Date, + limit: Int + ) async throws -> [HKQuantitySample] { + let predicate = HKQuery.predicateForSamples( + withStart: startDate, + end: endDate, + options: [.strictEndDate] + ) + let sort = NSSortDescriptor( + key: HKSampleSortIdentifierEndDate, + ascending: false + ) + + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: type, + predicate: predicate, + limit: limit, + sortDescriptors: [sort] + ) { _, samples, error in + if let error { + continuation.resume(throwing: error) + return + } + + continuation.resume( + returning: samples?.compactMap { $0 as? HKQuantitySample } ?? [] + ) + } + + healthStore.execute(query) + } + } + + private static func latestHeartbeatSeriesSample( + healthStore: HKHealthStore + ) async throws -> HKHeartbeatSeriesSample? { + let sort = NSSortDescriptor( + key: HKSampleSortIdentifierEndDate, + ascending: false + ) + + return try await withCheckedThrowingContinuation { continuation in + let query = HKSampleQuery( + sampleType: HKSeriesType.heartbeat(), + predicate: nil, + limit: 1, + sortDescriptors: [sort] + ) { _, samples, error in + if let error { + continuation.resume(throwing: error) + return + } + + continuation.resume( + returning: samples?.first as? HKHeartbeatSeriesSample + ) + } + + healthStore.execute(query) + } + } + + private static func makeNativeQuantitySample( + _ sample: HKQuantitySample, + metricType: HealthMetricType, + unit: HKUnit, + unitLabel: String + ) -> NativeHealthMetricSample? { + let value = sample.quantity.doubleValue(for: unit) + guard value.isFinite else { + return nil + } + + let source = sample.sourceRevision.source + let deviceName = nonEmpty(sample.device?.name) ?? nonEmpty(sample.device?.model) + + return NativeHealthMetricSample( + type: metricType, + value: value, + unit: unitLabel, + startDate: iso8601String(from: sample.startDate), + endDate: iso8601String(from: sample.endDate), + sourceName: nonEmpty(source.name), + sourceBundleIdentifier: nonEmpty(source.bundleIdentifier), + deviceName: deviceName + ) + } + + /// A conservative availability-and-recency score for optional context only. It is not a signal + /// quality score and must not be interpreted as confidence in a stress or medical conclusion. + private static func contextConfidence( + latestResting: HKQuantitySample?, + latestHrv: HKQuantitySample?, + heartRates: [HKQuantitySample], + fetchedAt: Date, + lookbackDays: Double + ) -> Double { + let horizon = max(lookbackDays * 86_400.0, 1.0) + + func freshness(of sample: HKSample) -> Double { + let age = max(fetchedAt.timeIntervalSince(sample.endDate), 0.0) + return min(max(1.0 - age / horizon, 0.0), 1.0) + } + + var score = 0.0 + if let latestResting { + score += 0.35 * freshness(of: latestResting) + } + if let latestHrv { + score += 0.35 * freshness(of: latestHrv) + } + if let latestHeartRate = heartRates.first { + let sampleCoverage = min(Double(heartRates.count) / 5.0, 1.0) + score += 0.20 * freshness(of: latestHeartRate) + score += 0.10 * sampleCoverage + } + + return min(max(score, 0.0), 1.0) + } + + private static func emptyContext( + available: Bool, + authorizationRequestSettled: Bool, + fetchedAt: Date + ) -> NativeHealthKitContext { + return NativeHealthKitContext( + available: available, + authorized: authorizationRequestSettled, + latestRestingHeartRate: nil, + latestHrvSdnn: nil, + recentHeartRateSamples: [], + contextConfidence: 0, + fetchedAt: iso8601String(from: fetchedAt) + ) + } + + private static func boundedLookbackDays(_ value: Double) -> Double { + guard value.isFinite else { + return defaultLookbackDays + } + return min(max(value, minimumLookbackDays), maximumLookbackDays) + } + + private static func iso8601String(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + } + + private static func nonEmpty(_ value: String?) -> String? { + guard let value else { + return nil + } + + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/packages/react-native-haptika-ppg/ios/Safety/PPGThermalMonitor.swift b/packages/react-native-haptika-ppg/ios/Safety/PPGThermalMonitor.swift new file mode 100644 index 0000000..8461dbf --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Safety/PPGThermalMonitor.swift @@ -0,0 +1,18 @@ +import Foundation + +struct PPGThermalMonitor { + func currentState() -> ThermalState { + switch ProcessInfo.processInfo.thermalState { + case .nominal: + return .nominal + case .fair: + return .fair + case .serious: + return .serious + case .critical: + return .critical + @unknown default: + return .unknown + } + } +} diff --git a/packages/react-native-haptika-ppg/ios/Safety/PPGThermalSafetyPolicy.swift b/packages/react-native-haptika-ppg/ios/Safety/PPGThermalSafetyPolicy.swift new file mode 100644 index 0000000..45f8918 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Safety/PPGThermalSafetyPolicy.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Pure, testable safety gate used before and during camera acquisition. +/// Serious and critical states stop the measurement; unknown is conservative +/// only in the sense that no torch escalation is attempted elsewhere. +public enum PPGThermalSafetyPolicy { + public static func shouldStopMeasurement(forStateName stateName: String) -> Bool { + switch stateName.lowercased() { + case "serious", "critical": + return true + case "nominal", "fair", "unknown": + return false + default: + return false + } + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/BPMEstimator.swift b/packages/react-native-haptika-ppg/ios/Signal/BPMEstimator.swift new file mode 100644 index 0000000..aead993 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/BPMEstimator.swift @@ -0,0 +1,196 @@ +import Foundation + +public struct FrequencyBPMEstimate: Codable, Equatable, Sendable { + public var bpm: Double? + public var autocorrelation: Double + public var confidence: Double + + public init(bpm: Double?, autocorrelation: Double, confidence: Double) { + self.bpm = bpm + self.autocorrelation = autocorrelation.isFinite ? autocorrelation : 0 + self.confidence = SignalStatistics.clamp01(confidence) + } +} + +public enum PPGBPMEstimator { + public static func estimate( + signal: [Double], + timestampsSeconds: [Double], + validityMask: [Bool], + peakDetection: PeakDetectionResult, + selectedChannel: PPGSignalChannel, + configuration: PPGProcessingConfiguration + ) throws -> BPMEstimate { + let configuration = try configuration.validated() + guard signal.count == timestampsSeconds.count, + signal.count == validityMask.count, + signal.count >= 5 + else { + throw PPGSignalError.insufficientSamples(required: 5, actual: signal.count) + } + + let acceptedIntervals = peakDetection.acceptedIntervals.map(\.durationMilliseconds) + let temporalBPM: Double? = acceptedIntervals.count >= 4 + ? 60_000 / SignalStatistics.median(acceptedIntervals) + : nil + let frequency = frequencyEstimate( + signal: signal, + timestampsSeconds: timestampsSeconds, + validityMask: validityMask, + minimumFrequencyHz: configuration.minimumFrequencyHz, + maximumFrequencyHz: configuration.maximumFrequencyHz + ) + + // Autocorrelation can lock onto the second harmonic (2×) or a sub-harmonic + // (0.5×). Prefer the equivalent candidate closest to the clean beat-to-beat + // estimate before judging the two methods as inconsistent. + let adjustedFrequencyBPM = temporalBPM.flatMap { temporal -> Double? in + guard let raw = frequency.bpm else { return nil } + return [raw, raw / 2, raw * 2] + .filter { + $0 >= configuration.minimumFrequencyHz * 60 + && $0 <= configuration.maximumFrequencyHz * 60 + } + .min(by: { abs($0 - temporal) < abs($1 - temporal) }) + } ?? frequency.bpm + let agreement = temporalBPM.flatMap { temporal in + adjustedFrequencyBPM.map { abs(temporal - $0) } + } + let agreementScore = agreement.map { + SignalStatistics.clamp01(1 - $0 / max(configuration.maximumMethodDisagreementBPM, 1e-9)) + } ?? 0 + + let finalBPM: Double? + if let temporalBPM, + let frequencyBPM = adjustedFrequencyBPM, + let agreement, + agreement <= configuration.maximumMethodDisagreementBPM { + finalBPM = 0.65 * temporalBPM + 0.35 * frequencyBPM + } else if let temporalBPM, + acceptedIntervals.count >= 8, + peakDetection.confidence >= 0.55 { + // A long, regular sequence of detected beats is sufficient for mean HR. + // Keep PRV subject to the stricter downstream interval-quality checks. + finalBPM = temporalBPM + } else if let frequencyBPM = adjustedFrequencyBPM, + frequency.confidence >= 0.55 { + finalBPM = frequencyBPM + } else { + finalBPM = nil + } + + let countScore = min(Double(acceptedIntervals.count) / 10, 1) + let confidence = 0.40 * peakDetection.confidence + + 0.25 * frequency.confidence + + 0.25 * agreementScore + + 0.10 * countScore + + return BPMEstimate( + bpm: finalBPM, + temporalBpm: temporalBPM, + frequencyBpm: adjustedFrequencyBPM, + methodAgreementBpm: agreement, + validPeakCount: peakDetection.peaks.count, + rejectedPeakCount: peakDetection.rejectedIntervalCount, + confidence: confidence, + selectedChannel: selectedChannel, + selectedPolarity: peakDetection.selectedPolarity + ) + } + + public static func frequencyEstimate( + signal: [Double], + timestampsSeconds: [Double], + validityMask: [Bool], + minimumFrequencyHz: Double, + maximumFrequencyHz: Double + ) -> FrequencyBPMEstimate { + guard signal.count == timestampsSeconds.count, + signal.count == validityMask.count, + signal.count >= 12, + minimumFrequencyHz > 0, + maximumFrequencyHz > minimumFrequencyHz + else { + return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0) + } + + let intervals = zip(timestampsSeconds, timestampsSeconds.dropFirst()) + .map { $1 - $0 } + .filter { $0.isFinite && $0 > 0 } + let sampleInterval = SignalStatistics.median(intervals) + guard sampleInterval > 0 else { + return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0) + } + let sampleRate = 1 / sampleInterval + let minimumLag = max(Int((sampleRate / maximumFrequencyHz).rounded(.down)), 2) + let maximumLag = min(Int((sampleRate / minimumFrequencyHz).rounded(.up)), signal.count / 2) + guard maximumLag > minimumLag else { + return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0) + } + + let validValues = signal.indices.compactMap { + validityMask[$0] && signal[$0].isFinite ? signal[$0] : nil + } + let center = SignalStatistics.mean(validValues) + var correlations: [Int: Double] = [:] + for lag in minimumLag...maximumLag { + var cross = 0.0 + var leftPower = 0.0 + var rightPower = 0.0 + var pairCount = 0 + for index in 0..<(signal.count - lag) { + guard validityMask[index], validityMask[index + lag] else { continue } + let left = signal[index] - center + let right = signal[index + lag] - center + guard left.isFinite, right.isFinite else { continue } + cross += left * right + leftPower += left * left + rightPower += right * right + pairCount += 1 + } + guard pairCount >= 10, leftPower > 1e-12, rightPower > 1e-12 else { continue } + correlations[lag] = cross / (leftPower * rightPower).squareRoot() + } + + guard !correlations.isEmpty, + let strongest = correlations.max(by: { $0.value < $1.value }), + strongest.value > 0.10 + else { + return FrequencyBPMEstimate(bpm: nil, autocorrelation: 0, confidence: 0) + } + + let localPeaks = correlations.keys.sorted().filter { lag in + let value = correlations[lag] ?? -.infinity + let left = correlations[lag - 1] ?? -.infinity + let right = correlations[lag + 1] ?? -.infinity + return value >= left && value >= right + } + let strongThreshold = max(strongest.value * 0.92, 0.10) + let chosenLag = localPeaks.first(where: { (correlations[$0] ?? 0) >= strongThreshold }) + ?? strongest.key + let centerCorrelation = correlations[chosenLag] ?? strongest.value + let leftCorrelation = correlations[chosenLag - 1] ?? centerCorrelation + let rightCorrelation = correlations[chosenLag + 1] ?? centerCorrelation + let denominator = leftCorrelation - 2 * centerCorrelation + rightCorrelation + let fractionalOffset: Double + if abs(denominator) > 1e-12 { + fractionalOffset = min( + max(0.5 * (leftCorrelation - rightCorrelation) / denominator, -0.5), + 0.5 + ) + } else { + fractionalOffset = 0 + } + let refinedLag = Double(chosenLag) + fractionalOffset + guard refinedLag > 0 else { + return FrequencyBPMEstimate(bpm: nil, autocorrelation: centerCorrelation, confidence: 0) + } + let bpm = 60 * sampleRate / refinedLag + let confidence = SignalStatistics.clamp01((centerCorrelation - 0.10) / 0.75) + return FrequencyBPMEstimate( + bpm: bpm.isFinite ? bpm : nil, + autocorrelation: centerCorrelation, + confidence: confidence + ) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/Filtering.swift b/packages/react-native-haptika-ppg/ios/Signal/Filtering.swift new file mode 100644 index 0000000..507a887 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/Filtering.swift @@ -0,0 +1,172 @@ +import Foundation + +public enum PPGSignalPreprocessor { + /// Robustly clips isolated outliers, subtracts a centered local baseline, and scales by MAD. + public static func detrendAndNormalize( + _ input: [Double], + sampleRateHz: Double, + baselineWindowSeconds: Double = 1.6 + ) -> [Double] { + guard !input.isEmpty, sampleRateHz.isFinite, sampleRateHz > 0 else { return [] } + + let finite = sanitize(input) + let center = SignalStatistics.median(finite) + let rawMAD = SignalStatistics.mad(finite, around: center) + let fallbackScale = max(SignalStatistics.standardDeviation(finite), 1e-9) + let clipScale = max(1.4826 * rawMAD, fallbackScale * 0.25, 1e-9) + let clipped = finite.map { min(max($0, center - 8 * clipScale), center + 8 * clipScale) } + + var window = max(Int((baselineWindowSeconds * sampleRateHz).rounded()), 3) + if window.isMultiple(of: 2) { window += 1 } + let baseline = centeredMovingAverage(clipped, window: window) + let detrended = zip(clipped, baseline).map { $0.0 - $0.1 } + let detrendedCenter = SignalStatistics.median(detrended) + let detrendedMAD = SignalStatistics.mad(detrended, around: detrendedCenter) + let detrendedSTD = SignalStatistics.standardDeviation(detrended) + let scale = max(1.4826 * detrendedMAD, detrendedSTD * 0.20, 1e-9) + + return detrended.map { value in + min(max((value - detrendedCenter) / scale, -12), 12) + } + } + + public static func centeredMovingAverage(_ input: [Double], window: Int) -> [Double] { + guard !input.isEmpty else { return [] } + let normalizedWindow = max(window, 1) + let half = normalizedWindow / 2 + return input.indices.map { index in + var sum = 0.0 + for offset in -half...half { + sum += input[SignalStatistics.reflectedIndex(index + offset, count: input.count)] + } + return sum / Double(2 * half + 1) + } + } + + public static func dilateInvalidRegions(_ mask: [Bool], radius: Int) -> [Bool] { + guard radius > 0, !mask.isEmpty else { return mask } + var difference = Array(repeating: 0, count: mask.count + 1) + for index in mask.indices where !mask[index] { + let lower = max(0, index - radius) + let upper = min(mask.count - 1, index + radius) + difference[lower] += 1 + difference[upper + 1] -= 1 + } + var active = 0 + return mask.indices.map { index in + active += difference[index] + return active == 0 + } + } + + private static func sanitize(_ input: [Double]) -> [Double] { + var lastFinite = input.first(where: \.isFinite) ?? 0 + return input.map { value in + if value.isFinite { lastFinite = value } + return lastFinite + } + } +} + +/// Symmetric, windowed-sinc FIR band-pass filter. Coefficients are regenerated from the actual +/// resampling frequency, so a 30 Hz and a 60 Hz capture use the same physiological pass band. +public struct PPGBandPassFilter: Equatable, Sendable { + public let sampleRateHz: Double + public let lowCutoffHz: Double + public let highCutoffHz: Double + public let coefficients: [Double] + + public init( + sampleRateHz: Double, + lowCutoffHz: Double, + highCutoffHz: Double, + tapCount requestedTapCount: Int? = nil + ) throws { + guard sampleRateHz.isFinite, + lowCutoffHz.isFinite, + highCutoffHz.isFinite, + sampleRateHz >= 10, + lowCutoffHz > 0, + highCutoffHz > lowCutoffHz, + highCutoffHz < sampleRateHz / 2 + else { + throw PPGSignalError.invalidConfiguration("band-pass frequencies must lie inside Nyquist") + } + + var tapCount = requestedTapCount + ?? min(max(Int((sampleRateHz * 4).rounded()), 31), 301) + tapCount = max(tapCount, 5) + if tapCount.isMultiple(of: 2) { tapCount += 1 } + + self.sampleRateHz = sampleRateHz + self.lowCutoffHz = lowCutoffHz + self.highCutoffHz = highCutoffHz + self.coefficients = Self.makeCoefficients( + sampleRateHz: sampleRateHz, + lowCutoffHz: lowCutoffHz, + highCutoffHz: highCutoffHz, + tapCount: tapCount + ) + } + + public var invalidRegionGuardRadius: Int { coefficients.count / 2 } + + /// Applies centered convolution. The symmetric impulse response introduces no peak-time shift. + public func apply(to input: [Double]) -> [Double] { + guard !input.isEmpty else { return [] } + let clean = input.map { $0.isFinite ? $0 : 0 } + let half = coefficients.count / 2 + var output = Array(repeating: 0.0, count: clean.count) + + for outputIndex in clean.indices { + var accumulator = 0.0 + for coefficientIndex in coefficients.indices { + let sourceIndex = SignalStatistics.reflectedIndex( + outputIndex + coefficientIndex - half, + count: clean.count + ) + accumulator += coefficients[coefficientIndex] * clean[sourceIndex] + } + output[outputIndex] = accumulator.isFinite ? accumulator : 0 + } + return output + } + + private static func makeCoefficients( + sampleRateHz: Double, + lowCutoffHz: Double, + highCutoffHz: Double, + tapCount: Int + ) -> [Double] { + let midpoint = tapCount / 2 + let low = lowCutoffHz / sampleRateHz + let high = highCutoffHz / sampleRateHz + var taps = (0.. Double in + let offset = Double(index - midpoint) + let idealHigh = 2 * high * sinc(2 * high * offset) + let idealLow = 2 * low * sinc(2 * low * offset) + let hamming = 0.54 - 0.46 * cos(2 * Double.pi * Double(index) / Double(tapCount - 1)) + return (idealHigh - idealLow) * hamming + } + + let centerFrequency = (lowCutoffHz * highCutoffHz).squareRoot() + var real = 0.0 + var imaginary = 0.0 + for index in taps.indices { + let offset = Double(index - midpoint) + let angle = -2 * Double.pi * centerFrequency * offset / sampleRateHz + real += taps[index] * cos(angle) + imaginary += taps[index] * sin(angle) + } + let gain = (real * real + imaginary * imaginary).squareRoot() + if gain > 1e-12 { + taps = taps.map { $0 / gain } + } + return taps + } + + private static func sinc(_ value: Double) -> Double { + guard abs(value) > 1e-12 else { return 1 } + return sin(Double.pi * value) / (Double.pi * value) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/Models.swift b/packages/react-native-haptika-ppg/ios/Signal/Models.swift new file mode 100644 index 0000000..980abd0 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/Models.swift @@ -0,0 +1,523 @@ +import Foundation + +public enum PPGSignalError: Error, Equatable, Sendable { + case invalidConfiguration(String) + case insufficientSamples(required: Int, actual: Int) + case insufficientDuration(requiredSeconds: Double, actualSeconds: Double) + case invalidTimestamps + case noUsableChannel +} + +/// Compact numeric output expected from the camera frame extractor. +/// No image data is retained by the signal-processing core. +public struct PPGFrameSample: Codable, Equatable, Sendable { + public var timestampSeconds: Double + public var red: Double + public var green: Double + public var blue: Double + public var luminance: Double + public var contactConfidence: Double + public var saturationRatio: Double + public var motionArtifactScore: Double + + public init( + timestampSeconds: Double, + red: Double, + green: Double, + blue: Double? = nil, + luminance: Double, + contactConfidence: Double = 1, + saturationRatio: Double = 0, + motionArtifactScore: Double = 0 + ) { + self.timestampSeconds = timestampSeconds + self.red = red + self.green = green + let reconstructedBlue = (luminance - 0.2126 * red - 0.7152 * green) / 0.0722 + self.blue = blue ?? min(max(reconstructedBlue.isFinite ? reconstructedBlue : luminance, 0), 255) + self.luminance = luminance + self.contactConfidence = Self.clamp01(contactConfidence) + self.saturationRatio = Self.clamp01(saturationRatio) + self.motionArtifactScore = Self.clamp01(motionArtifactScore) + } + + public var hasFiniteSignalValues: Bool { + timestampSeconds.isFinite && red.isFinite && green.isFinite + && blue.isFinite && luminance.isFinite + } + + private static func clamp01(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) + } +} + +public enum PPGSignalChannel: String, CaseIterable, Codable, Equatable, Sendable { + case red + case green + case blue + case luminance + case greenRedRatio + case blueRedRatio + case normalizedBlend +} + +/// Publication gates for the three product decisions made from one camera capture. +/// +/// These thresholds deliberately do not mean the same thing: +/// - mean BPM can be published from a technically usable periodic signal; +/// - beat-to-beat PRV needs substantially better timing quality; +/// - the potential-activation index is gated again by the product layer and a +/// personalized baseline. It must never be presented as a medical stress measure. +/// +/// Keeping the values named here prevents a permissive live-preview threshold from +/// accidentally becoming the PRV or product-index threshold. +public enum PPGPublicationThresholds { + /// Minimum final technical score for a definitive mean BPM. + public static let bpmQualityScore = 0.45 + /// Minimum temporal/frequency estimator confidence for a definitive mean BPM. + public static let bpmConfidence = 0.40 + /// Minimum final technical score for beat-to-beat PRV to be marked reliable. + public static let prvQualityScore = 0.80 + /// Minimum estimator confidence for beat-to-beat PRV to be marked reliable. + public static let prvBpmConfidence = 0.70 + /// Product-layer signal gate used before calculating the potential index. + public static let potentialIndexQualityScore = 0.65 + /// Product-layer minimum number of accepted pulse intervals. + public static let potentialIndexMinimumIntervals = 20 +} + +public struct PPGProcessingConfiguration: Codable, Equatable, Sendable { + public var targetSampleRateHz: Double + public var minimumFrequencyHz: Double + public var maximumFrequencyHz: Double + public var minimumAnalysisDurationSeconds: Double + public var maximumInterpolationGapSeconds: Double + public var longGapLimitSeconds: Double + public var minimumContactConfidence: Double + public var maximumSaturationRatio: Double + public var maximumMotionArtifactScore: Double + public var minimumPeakProminence: Double + public var minimumPeakAmplitude: Double + public var refractoryPeriodSeconds: Double + public var maximumMethodDisagreementBPM: Double + public var minimumQualityScore: Double + public var minimumIntervalsForPRV: Int + public var maximumRejectedIntervalRatioForPRV: Double + public var filterTapCount: Int? + + public init( + targetSampleRateHz: Double = 30, + minimumFrequencyHz: Double = 0.7, + maximumFrequencyHz: Double = 3.5, + minimumAnalysisDurationSeconds: Double = 45, + maximumInterpolationGapSeconds: Double = 0.20, + longGapLimitSeconds: Double = 0.80, + minimumContactConfidence: Double = 0.55, + maximumSaturationRatio: Double = 0.70, + maximumMotionArtifactScore: Double = 0.65, + minimumPeakProminence: Double = 0.35, + minimumPeakAmplitude: Double = 0.10, + refractoryPeriodSeconds: Double = 0.25, + maximumMethodDisagreementBPM: Double = 8, + minimumQualityScore: Double = PPGPublicationThresholds.potentialIndexQualityScore, + minimumIntervalsForPRV: Int = 20, + maximumRejectedIntervalRatioForPRV: Double = 0.20, + filterTapCount: Int? = nil + ) { + self.targetSampleRateHz = targetSampleRateHz + self.minimumFrequencyHz = minimumFrequencyHz + self.maximumFrequencyHz = maximumFrequencyHz + self.minimumAnalysisDurationSeconds = minimumAnalysisDurationSeconds + self.maximumInterpolationGapSeconds = maximumInterpolationGapSeconds + self.longGapLimitSeconds = longGapLimitSeconds + self.minimumContactConfidence = Self.clamp01(minimumContactConfidence) + self.maximumSaturationRatio = Self.clamp01(maximumSaturationRatio) + self.maximumMotionArtifactScore = Self.clamp01(maximumMotionArtifactScore) + self.minimumPeakProminence = max(minimumPeakProminence, 0) + self.minimumPeakAmplitude = max(minimumPeakAmplitude, 0) + self.refractoryPeriodSeconds = max(refractoryPeriodSeconds, 0) + self.maximumMethodDisagreementBPM = max(maximumMethodDisagreementBPM, 0) + self.minimumQualityScore = Self.clamp01(minimumQualityScore) + self.minimumIntervalsForPRV = max(minimumIntervalsForPRV, 2) + self.maximumRejectedIntervalRatioForPRV = Self.clamp01(maximumRejectedIntervalRatioForPRV) + self.filterTapCount = filterTapCount + } + + public static let production = PPGProcessingConfiguration() + + public func validated() throws -> Self { + guard targetSampleRateHz.isFinite, targetSampleRateHz >= 10 else { + throw PPGSignalError.invalidConfiguration("targetSampleRateHz must be at least 10 Hz") + } + guard minimumFrequencyHz.isFinite, + maximumFrequencyHz.isFinite, + minimumFrequencyHz > 0, + maximumFrequencyHz > minimumFrequencyHz, + maximumFrequencyHz < targetSampleRateHz / 2 + else { + throw PPGSignalError.invalidConfiguration("frequency band must be inside (0, Nyquist)") + } + guard minimumAnalysisDurationSeconds.isFinite, minimumAnalysisDurationSeconds > 0 else { + throw PPGSignalError.invalidConfiguration("minimumAnalysisDurationSeconds must be positive") + } + guard maximumInterpolationGapSeconds.isFinite, + maximumInterpolationGapSeconds > 0, + longGapLimitSeconds.isFinite, + longGapLimitSeconds >= maximumInterpolationGapSeconds + else { + throw PPGSignalError.invalidConfiguration("gap limits are inconsistent") + } + if let filterTapCount, filterTapCount < 5 { + throw PPGSignalError.invalidConfiguration("filterTapCount must be at least 5") + } + return self + } + + private static func clamp01(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) + } +} + +public struct TimestampedValue: Equatable, Sendable { + public var timestampSeconds: Double + public var value: Double + + public init(timestampSeconds: Double, value: Double) { + self.timestampSeconds = timestampSeconds + self.value = value + } +} + +public struct TimebaseDiagnostics: Codable, Equatable, Sendable { + public var inputSampleCount: Int + public var usableInputSampleCount: Int + public var discardedSampleCount: Int + public var duplicateTimestampCount: Int + public var outOfOrderTimestampCount: Int + public var durationSeconds: Double + public var usableDurationSeconds: Double + public var estimatedSampleRateHz: Double + public var frameIntervalMADSeconds: Double + public var droppedFrameRatio: Double + public var maximumGapSeconds: Double + public var longGapDurationSeconds: Double + + public init( + inputSampleCount: Int, + usableInputSampleCount: Int, + discardedSampleCount: Int, + duplicateTimestampCount: Int, + outOfOrderTimestampCount: Int, + durationSeconds: Double, + usableDurationSeconds: Double, + estimatedSampleRateHz: Double, + frameIntervalMADSeconds: Double, + droppedFrameRatio: Double, + maximumGapSeconds: Double, + longGapDurationSeconds: Double + ) { + self.inputSampleCount = inputSampleCount + self.usableInputSampleCount = usableInputSampleCount + self.discardedSampleCount = discardedSampleCount + self.duplicateTimestampCount = duplicateTimestampCount + self.outOfOrderTimestampCount = outOfOrderTimestampCount + self.durationSeconds = max(durationSeconds, 0) + self.usableDurationSeconds = max(usableDurationSeconds, 0) + self.estimatedSampleRateHz = max(estimatedSampleRateHz, 0) + self.frameIntervalMADSeconds = max(frameIntervalMADSeconds, 0) + self.droppedFrameRatio = min(max(droppedFrameRatio, 0), 1) + self.maximumGapSeconds = max(maximumGapSeconds, 0) + self.longGapDurationSeconds = max(longGapDurationSeconds, 0) + } +} + +public struct ResampledSignal: Equatable, Sendable { + public var timestampsSeconds: [Double] + public var values: [Double] + public var validityMask: [Bool] + public var diagnostics: TimebaseDiagnostics + + public init( + timestampsSeconds: [Double], + values: [Double], + validityMask: [Bool], + diagnostics: TimebaseDiagnostics + ) { + precondition(timestampsSeconds.count == values.count) + precondition(values.count == validityMask.count) + self.timestampsSeconds = timestampsSeconds + self.values = values + self.validityMask = validityMask + self.diagnostics = diagnostics + } +} + +public struct DetectedPeak: Codable, Equatable, Sendable { + public var sampleIndex: Int + public var timestampSeconds: Double + public var amplitude: Double + public var prominence: Double + + public init(sampleIndex: Int, timestampSeconds: Double, amplitude: Double, prominence: Double) { + self.sampleIndex = sampleIndex + self.timestampSeconds = timestampSeconds + self.amplitude = amplitude + self.prominence = max(prominence, 0) + } +} + +public enum PulseIntervalRejectionReason: String, Codable, Equatable, Sendable { + case outsidePhysiologicalRange + case localOutlier + case invalidSignalRegion + case lowMorphologyQuality +} + +public struct PulseInterval: Codable, Equatable, Sendable { + public var sourceIndex: Int + public var startSeconds: Double + public var endSeconds: Double + public var durationMilliseconds: Double + public var rejectionReason: PulseIntervalRejectionReason? + /// Per-beat technical quality. This is not a medical confidence score. + public var qualityScore: Double + + public init( + sourceIndex: Int, + startSeconds: Double, + endSeconds: Double, + durationMilliseconds: Double, + rejectionReason: PulseIntervalRejectionReason? = nil, + qualityScore: Double = 1 + ) { + self.sourceIndex = sourceIndex + self.startSeconds = startSeconds + self.endSeconds = endSeconds + self.durationMilliseconds = durationMilliseconds + self.rejectionReason = rejectionReason + self.qualityScore = min(max(qualityScore.isFinite ? qualityScore : 0, 0), 1) + } + + public var isAccepted: Bool { rejectionReason == nil } +} + +public struct PeakDetectionResult: Equatable, Sendable { + public var peaks: [DetectedPeak] + public var intervals: [PulseInterval] + public var selectedPolarity: Int + public var confidence: Double + + public init( + peaks: [DetectedPeak], + intervals: [PulseInterval], + selectedPolarity: Int, + confidence: Double + ) { + self.peaks = peaks + self.intervals = intervals + self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1 + self.confidence = Self.clamp01(confidence) + } + + public var acceptedIntervals: [PulseInterval] { intervals.filter(\.isAccepted) } + public var rejectedIntervalCount: Int { intervals.count - acceptedIntervals.count } + + private static func clamp01(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) + } +} + +public struct BPMEstimate: Codable, Equatable, Sendable { + /// Final BPM. It is nil when the independent temporal and frequency estimates disagree. + public var bpm: Double? + public var temporalBpm: Double? + public var frequencyBpm: Double? + public var methodAgreementBpm: Double? + public var validPeakCount: Int + public var rejectedPeakCount: Int + public var confidence: Double + public var selectedChannel: PPGSignalChannel + public var selectedPolarity: Int + + public init( + bpm: Double?, + temporalBpm: Double?, + frequencyBpm: Double?, + methodAgreementBpm: Double?, + validPeakCount: Int, + rejectedPeakCount: Int, + confidence: Double, + selectedChannel: PPGSignalChannel, + selectedPolarity: Int + ) { + self.bpm = bpm + self.temporalBpm = temporalBpm + self.frequencyBpm = frequencyBpm + self.methodAgreementBpm = methodAgreementBpm + self.validPeakCount = max(validPeakCount, 0) + self.rejectedPeakCount = max(rejectedPeakCount, 0) + self.confidence = Self.clamp01(confidence) + self.selectedChannel = selectedChannel + self.selectedPolarity = selectedPolarity >= 0 ? 1 : -1 + } + + private static func clamp01(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) + } +} + +public struct PRVMetrics: Codable, Equatable, Sendable { + public var estimatedRmssdMs: Double? + public var estimatedSdnnMs: Double? + public var meanIpiMs: Double? + public var medianIpiMs: Double? + public var pnn50Percent: Double? + public var rangeIpiMs: Double? + public var modeIpiMs: Double? + public var amo50Percent: Double? + public var coefficientOfVariation: Double? + public var validIntervalCount: Int + public var rejectedIntervalCount: Int + public var rejectedIntervalRatio: Double + public var reliable: Bool + + public init( + estimatedRmssdMs: Double?, + estimatedSdnnMs: Double?, + meanIpiMs: Double?, + medianIpiMs: Double?, + pnn50Percent: Double?, + rangeIpiMs: Double?, + modeIpiMs: Double?, + amo50Percent: Double?, + coefficientOfVariation: Double?, + validIntervalCount: Int, + rejectedIntervalCount: Int, + rejectedIntervalRatio: Double, + reliable: Bool + ) { + self.estimatedRmssdMs = estimatedRmssdMs + self.estimatedSdnnMs = estimatedSdnnMs + self.meanIpiMs = meanIpiMs + self.medianIpiMs = medianIpiMs + self.pnn50Percent = pnn50Percent + self.rangeIpiMs = rangeIpiMs + self.modeIpiMs = modeIpiMs + self.amo50Percent = amo50Percent + self.coefficientOfVariation = coefficientOfVariation + self.validIntervalCount = max(validIntervalCount, 0) + self.rejectedIntervalCount = max(rejectedIntervalCount, 0) + self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio) + self.reliable = reliable + } + + private static func clamp01(_ value: Double) -> Double { + guard value.isFinite else { return 1 } + return min(max(value, 0), 1) + } +} + +public enum SignalQualityLevel: String, Codable, Equatable, Sendable { + case good + case medium + case poor + case invalid +} + +public enum SignalQualityIssue: String, Codable, Equatable, Hashable, Sendable { + case insufficientDuration + case invalidTimestamps + case longInterruption + case tooManyDroppedFrames + case irregularTimestamps + case poorFingerContact + case excessiveSaturation + case motionArtifacts + case lowPulsatileAmplitude + case lowSignalToNoise + case tooFewPeaks + case inconsistentBPMMethods + case tooManyRejectedIntervals + case unstableChannel + case nonFiniteInput +} + +public struct SignalQualityResult: Codable, Equatable, Sendable { + public var score: Double + public var level: SignalQualityLevel + public var usableDurationSeconds: Double + public var droppedFrameRatio: Double + public var saturationRatio: Double + public var rejectedIntervalRatio: Double + /// Signal-to-residual ratio in decibels around the selected pulse frequency. + public var signalToNoiseRatio: Double? + public var motionArtifactRatio: Double + public var methodAgreementScore: Double + public var issues: [SignalQualityIssue] + + public init( + score: Double, + level: SignalQualityLevel, + usableDurationSeconds: Double, + droppedFrameRatio: Double, + saturationRatio: Double, + rejectedIntervalRatio: Double, + signalToNoiseRatio: Double?, + motionArtifactRatio: Double, + methodAgreementScore: Double, + issues: [SignalQualityIssue] + ) { + self.score = Self.clamp01(score) + self.level = level + self.usableDurationSeconds = max(usableDurationSeconds, 0) + self.droppedFrameRatio = Self.clamp01(droppedFrameRatio) + self.saturationRatio = Self.clamp01(saturationRatio) + self.rejectedIntervalRatio = Self.clamp01(rejectedIntervalRatio) + self.signalToNoiseRatio = signalToNoiseRatio?.isFinite == true ? signalToNoiseRatio : nil + self.motionArtifactRatio = Self.clamp01(motionArtifactRatio) + self.methodAgreementScore = Self.clamp01(methodAgreementScore) + self.issues = Array(Set(issues)).sorted { $0.rawValue < $1.rawValue } + } + + private static func clamp01(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) + } +} + +public enum PPGAnalysisStatus: String, Codable, Equatable, Sendable { + case valid + case invalid +} + +public struct PPGAnalysisResult: Codable, Equatable, Sendable { + public var status: PPGAnalysisStatus + public var bpm: BPMEstimate + public var prv: PRVMetrics + public var quality: SignalQualityResult + public var peaks: [DetectedPeak] + public var intervals: [PulseInterval] + public var timebase: TimebaseDiagnostics + + public init( + status: PPGAnalysisStatus, + bpm: BPMEstimate, + prv: PRVMetrics, + quality: SignalQualityResult, + peaks: [DetectedPeak], + intervals: [PulseInterval], + timebase: TimebaseDiagnostics + ) { + self.status = status + self.bpm = bpm + self.prv = prv + self.quality = quality + self.peaks = peaks + self.intervals = intervals + self.timebase = timebase + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/PPGCoherencePolicy.swift b/packages/react-native-haptika-ppg/ios/Signal/PPGCoherencePolicy.swift new file mode 100644 index 0000000..ebac018 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/PPGCoherencePolicy.swift @@ -0,0 +1,60 @@ +import Foundation + +public enum PPGCoherencePolicy { + public static let spotMaximumDurationSeconds = 120.0 + public static let coherenceMaximumDurationSeconds = 300.0 + public static let coherenceBufferDurationSeconds = 75.0 + + public static func maximumDurationSeconds(isCoherence: Bool) -> Double { + isCoherence ? coherenceMaximumDurationSeconds : spotMaximumDurationSeconds + } +} + +public struct PPGLiveAcceptedBeat: Equatable, Sendable { + public let sequence: Int + public let timestampSeconds: Double + public let intervalMs: Double + public let confidence: Double +} + +/// Deduplicates accepted intervals emitted by overlapping live-analysis windows. +public struct PPGLiveBeatTracker: Sendable { + private var lastPublishedTimestamp: Double? + private var sequence = 0 + + public init() {} + + public mutating func ingest(_ intervals: [PulseInterval]) -> [PPGLiveAcceptedBeat] { + let sorted = intervals + .filter { + $0.isAccepted && $0.endSeconds.isFinite && $0.durationMilliseconds.isFinite + && (300...2_000).contains($0.durationMilliseconds) + } + .sorted { $0.endSeconds < $1.endSeconds } + let previous = lastPublishedTimestamp + let newIntervals = sorted.filter { interval in + guard let previous else { return true } + // Physiological intervals are at least 300 ms. A 250 ms boundary treats + // small peak refinements as the same beat without swallowing a real one. + return interval.endSeconds > previous + 0.25 + } + let beats = newIntervals.map { interval -> PPGLiveAcceptedBeat in + sequence += 1 + return PPGLiveAcceptedBeat( + sequence: sequence, + timestampSeconds: interval.endSeconds, + intervalMs: interval.durationMilliseconds, + confidence: min(max(interval.qualityScore, 0), 1) + ) + } + if let latest = sorted.last?.endSeconds { + lastPublishedTimestamp = max(lastPublishedTimestamp ?? latest, latest) + } + return beats + } + + public mutating func reset() { + lastPublishedTimestamp = nil + sequence = 0 + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/PPGSyntheticSignalGenerator.swift b/packages/react-native-haptika-ppg/ios/Signal/PPGSyntheticSignalGenerator.swift new file mode 100644 index 0000000..9b1f331 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/PPGSyntheticSignalGenerator.swift @@ -0,0 +1,242 @@ +import Foundation + +/// A deterministic artifact window used by ``PPGSyntheticSignalConfiguration``. +public struct PPGSyntheticArtifactWindow: Codable, Equatable, Sendable { + public var startSeconds: Double + public var endSeconds: Double + public var intensity: Double + + public init(startSeconds: Double, endSeconds: Double, intensity: Double = 1) { + self.startSeconds = max(startSeconds.isFinite ? startSeconds : 0, 0) + self.endSeconds = max(endSeconds.isFinite ? endSeconds : 0, self.startSeconds) + self.intensity = min(max(intensity.isFinite ? intensity : 0, 0), 1) + } + + func intensity(at time: Double) -> Double { + (startSeconds...endSeconds).contains(time) ? intensity : 0 + } +} + +/// Configuration for a numeric PPG camera fixture. +/// +/// The generator creates the same compact RGB/luminance/time values consumed by +/// production signal processing. It never creates or retains images. All random +/// noise is seeded, so a failing test can be reproduced byte for byte. +public struct PPGSyntheticSignalConfiguration: Codable, Equatable, Sendable { + public var sampleRateHz: Double + public var durationSeconds: Double + public var startingBPM: Double + public var endingBPM: Double + /// Repeating beat-to-beat offsets, in milliseconds, added to the nominal IPI. + public var intervalVariationMilliseconds: [Double] + public var noiseAmplitude: Double + public var driftAmplitude: Double + public var driftFrequencyHz: Double + public var movementWindows: [PPGSyntheticArtifactWindow] + public var saturationWindows: [PPGSyntheticArtifactWindow] + /// Every nth nominal camera frame is omitted. Nil keeps every frame. + public var dropEveryNthFrame: Int? + /// Suppresses the pulse cycle beginning at each listed zero-based beat index. + public var missingBeatIndices: Set + /// Adds a narrow artificial pulse at each supplied timestamp. + public var falsePeakTimesSeconds: [Double] + public var seed: UInt64 + + public init( + sampleRateHz: Double = 30, + durationSeconds: Double = 60, + startingBPM: Double = 75, + endingBPM: Double? = nil, + intervalVariationMilliseconds: [Double] = [], + noiseAmplitude: Double = 0, + driftAmplitude: Double = 0, + driftFrequencyHz: Double = 0.05, + movementWindows: [PPGSyntheticArtifactWindow] = [], + saturationWindows: [PPGSyntheticArtifactWindow] = [], + dropEveryNthFrame: Int? = nil, + missingBeatIndices: Set = [], + falsePeakTimesSeconds: [Double] = [], + seed: UInt64 = 0x4841_5054_494B_4101 + ) { + self.sampleRateHz = sampleRateHz + self.durationSeconds = durationSeconds + self.startingBPM = startingBPM + self.endingBPM = endingBPM ?? startingBPM + self.intervalVariationMilliseconds = intervalVariationMilliseconds + self.noiseAmplitude = max(noiseAmplitude, 0) + self.driftAmplitude = max(driftAmplitude, 0) + self.driftFrequencyHz = max(driftFrequencyHz, 0) + self.movementWindows = movementWindows + self.saturationWindows = saturationWindows + self.dropEveryNthFrame = dropEveryNthFrame + self.missingBeatIndices = missingBeatIndices + self.falsePeakTimesSeconds = falsePeakTimesSeconds + self.seed = seed + } + + public func validated() throws -> Self { + guard sampleRateHz.isFinite, sampleRateHz >= 10, sampleRateHz <= 240 else { + throw PPGSignalError.invalidConfiguration("synthetic sampleRateHz must be in 10...240") + } + guard durationSeconds.isFinite, durationSeconds > 0 else { + throw PPGSignalError.invalidConfiguration("synthetic durationSeconds must be positive") + } + guard startingBPM.isFinite, endingBPM.isFinite, + (30...240).contains(startingBPM), (30...240).contains(endingBPM) + else { + throw PPGSignalError.invalidConfiguration("synthetic BPM must be in 30...240") + } + guard intervalVariationMilliseconds.allSatisfy({ $0.isFinite && abs($0) <= 300 }) else { + throw PPGSignalError.invalidConfiguration("synthetic interval variation must be finite and <= 300 ms") + } + if let dropEveryNthFrame, dropEveryNthFrame < 2 { + throw PPGSignalError.invalidConfiguration("dropEveryNthFrame must be at least 2") + } + return self + } +} + +public struct PPGSyntheticSignal: Equatable, Sendable { + public var samples: [PPGFrameSample] + public var beatTimesSeconds: [Double] + public var nominalIntervalsMilliseconds: [Double] + + public init( + samples: [PPGFrameSample], + beatTimesSeconds: [Double], + nominalIntervalsMilliseconds: [Double] + ) { + self.samples = samples + self.beatTimesSeconds = beatTimesSeconds + self.nominalIntervalsMilliseconds = nominalIntervalsMilliseconds + } +} + +public enum PPGSyntheticSignalGenerator { + public static func generate( + configuration input: PPGSyntheticSignalConfiguration + ) throws -> PPGSyntheticSignal { + let configuration = try input.validated() + let beatSchedule = makeBeatSchedule(configuration) + let frameCount = Int((configuration.durationSeconds * configuration.sampleRateHz).rounded(.down)) + 1 + var random = SeededRandomNumberGenerator(seed: configuration.seed) + var samples: [PPGFrameSample] = [] + samples.reserveCapacity(frameCount) + + var beatIndex = 0 + for frameIndex in 0.. 0, frameIndex.isMultiple(of: nth) { + continue + } + let time = Double(frameIndex) / configuration.sampleRateHz + while beatIndex + 1 < beatSchedule.times.count, + beatSchedule.times[beatIndex + 1] <= time { + beatIndex += 1 + } + let currentBeat = beatSchedule.times[min(beatIndex, beatSchedule.times.count - 1)] + let nextBeat = beatIndex + 1 < beatSchedule.times.count + ? beatSchedule.times[beatIndex + 1] + : currentBeat + 60 / configuration.endingBPM + let phase = min(max((time - currentBeat) / max(nextBeat - currentBeat, 1e-6), 0), 1) + var pulse = cos(2 * Double.pi * phase) + 0.18 * cos(4 * Double.pi * phase) + if configuration.missingBeatIndices.contains(beatIndex) { pulse = 0 } + for falsePeakTime in configuration.falsePeakTimesSeconds where falsePeakTime.isFinite { + let distance = (time - falsePeakTime) / 0.055 + pulse += 1.25 * exp(-0.5 * distance * distance) + } + + let drift = configuration.driftAmplitude + * sin(2 * Double.pi * configuration.driftFrequencyHz * time) + let movement = configuration.movementWindows.map { $0.intensity(at: time) }.max() ?? 0 + let saturation = configuration.saturationWindows.map { $0.intensity(at: time) }.max() ?? 0 + let baseNoise = random.nextGaussian() * configuration.noiseAmplitude + let movementNoise = random.nextGaussian() * movement * 16 + let commonLight = 1 + drift + movementNoise * 0.006 + + var red = 176 * commonLight + 2.8 * pulse + 0.55 * baseNoise + movementNoise + var green = 92 * commonLight + 6.2 * pulse + baseNoise - 0.35 * movementNoise + var blue = 68 * commonLight + 1.8 * pulse + 0.45 * baseNoise + 0.20 * movementNoise + if saturation > 0 { + red = red * (1 - saturation) + 255 * saturation + green = green * (1 - saturation) + 255 * saturation + blue = blue * (1 - saturation) + 230 * saturation + } + red = min(max(red, 0), 255) + green = min(max(green, 0), 255) + blue = min(max(blue, 0), 255) + let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue + samples.append( + PPGFrameSample( + timestampSeconds: time, + red: red, + green: green, + blue: blue, + luminance: luminance, + contactConfidence: max(0.95 - 0.25 * movement, 0), + saturationRatio: saturation, + motionArtifactScore: movement + ) + ) + } + + return PPGSyntheticSignal( + samples: samples, + beatTimesSeconds: beatSchedule.times, + nominalIntervalsMilliseconds: beatSchedule.intervals + ) + } + + private static func makeBeatSchedule( + _ configuration: PPGSyntheticSignalConfiguration + ) -> (times: [Double], intervals: [Double]) { + var times = [0.0] + var intervals: [Double] = [] + var beatIndex = 0 + while let last = times.last, last < configuration.durationSeconds { + let progress = min(max(last / configuration.durationSeconds, 0), 1) + let bpm = configuration.startingBPM + + (configuration.endingBPM - configuration.startingBPM) * progress + let variation = configuration.intervalVariationMilliseconds.isEmpty + ? 0 + : configuration.intervalVariationMilliseconds[ + beatIndex % configuration.intervalVariationMilliseconds.count + ] + let intervalMs = max(60_000 / bpm + variation, 250) + intervals.append(intervalMs) + times.append(last + intervalMs / 1_000) + beatIndex += 1 + } + return (times, intervals) + } +} + +private struct SeededRandomNumberGenerator: RandomNumberGenerator { + private var state: UInt64 + private var spareGaussian: Double? + + init(seed: UInt64) { + state = seed == 0 ? 0x9E37_79B9_7F4A_7C15 : seed + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var value = state + value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9 + value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB + return value ^ (value >> 31) + } + + mutating func nextGaussian() -> Double { + if let spareGaussian { + self.spareGaussian = nil + return spareGaussian + } + let u1 = max(Double(next()) / Double(UInt64.max), 1e-12) + let u2 = Double(next()) / Double(UInt64.max) + let radius = sqrt(-2 * log(u1)) + let angle = 2 * Double.pi * u2 + spareGaussian = radius * sin(angle) + return radius * cos(angle) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/PRVCalculator.swift b/packages/react-native-haptika-ppg/ios/Signal/PRVCalculator.swift new file mode 100644 index 0000000..4af44d2 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/PRVCalculator.swift @@ -0,0 +1,143 @@ +import Foundation + +public struct PRVCalculationResult: Equatable, Sendable { + public var metrics: PRVMetrics + public var intervals: [PulseInterval] + + public init(metrics: PRVMetrics, intervals: [PulseInterval]) { + self.metrics = metrics + self.intervals = intervals + } +} + +public enum PPGPRVCalculator { + public static func calculate( + intervals inputIntervals: [PulseInterval], + configuration: PPGProcessingConfiguration + ) throws -> PRVCalculationResult { + let configuration = try configuration.validated() + var intervals = inputIntervals + let minimumInterval = 1_000 / configuration.maximumFrequencyHz + let maximumInterval = 1_000 / configuration.minimumFrequencyHz + + for index in intervals.indices where intervals[index].rejectionReason == nil { + let value = intervals[index].durationMilliseconds + if !value.isFinite || value < minimumInterval || value > maximumInterval { + intervals[index].rejectionReason = .outsidePhysiologicalRange + } + } + + // Filter against a moving neighbourhood (±13 intervals), not one global + // median. This follows gradual physiological changes while isolating a + // missed/double peak or a short movement artifact. Decisions use a frozen + // snapshot so one rejected beat cannot cascade into later rejections. + let initiallyAccepted = Set(intervals.indices.filter { intervals[$0].isAccepted }) + if initiallyAccepted.count >= 3 { + for index in intervals.indices where initiallyAccepted.contains(index) { + let lower = max(0, index - 13) + let upper = min(intervals.count - 1, index + 13) + let localValues = (lower...upper).compactMap { neighbour -> Double? in + guard neighbour != index, initiallyAccepted.contains(neighbour) else { return nil } + return intervals[neighbour].durationMilliseconds + } + guard localValues.count >= 2 else { continue } + let center = SignalStatistics.median(localValues) + let robustDeviation = 1.4826 * SignalStatistics.mad(localValues, around: center) + let allowedDeviation = max(center * 0.20, 3.5 * robustDeviation, 70) + let value = intervals[index].durationMilliseconds + let resemblesMissedBeat = abs(value - 2 * center) <= max(0.18 * center, 70) + let resemblesDoublePeak = abs(2 * value - center) <= max(0.18 * center, 70) + if abs(value - center) > allowedDeviation + || resemblesMissedBeat + || resemblesDoublePeak { + intervals[index].rejectionReason = .localOutlier + } + } + } + + let accepted = intervals.filter(\.isAccepted) + let values = accepted.map(\.durationMilliseconds) + let rejectedCount = intervals.count - accepted.count + let rejectedRatio = intervals.isEmpty + ? 1 + : Double(rejectedCount) / Double(intervals.count) + + let median = values.isEmpty ? nil : SignalStatistics.median(values) + let mean = values.isEmpty ? nil : SignalStatistics.mean(values) + let sdnn = values.count >= 2 ? SignalStatistics.standardDeviation(values) : nil + let coefficientOfVariation: Double? + if let median, let sdnn, median > 0 { + coefficientOfVariation = sdnn / median + } else { + coefficientOfVariation = nil + } + + var successiveDifferences: [Double] = [] + for pair in zip(accepted, accepted.dropFirst()) { + // Do not bridge over a rejected or missing beat: it would create a fictitious PRV difference. + guard pair.1.sourceIndex == pair.0.sourceIndex + 1 else { continue } + let difference = pair.1.durationMilliseconds - pair.0.durationMilliseconds + successiveDifferences.append(difference) + } + let squaredSuccessiveDifferences = successiveDifferences.map { $0 * $0 } + let rmssd = squaredSuccessiveDifferences.isEmpty + ? nil + : SignalStatistics.mean(squaredSuccessiveDifferences).squareRoot() + let pnn50Percent = successiveDifferences.isEmpty + ? nil + : 100 * Double(successiveDifferences.filter { abs($0) > 50 }.count) + / Double(successiveDifferences.count) + let rangeIpiMs: Double? = values.count >= 2 + ? (values.max() ?? 0) - (values.min() ?? 0) + : nil + let histogram = Dictionary(grouping: values) { Int(floor($0 / 50)) } + let modalBin = histogram.max { lhs, rhs in + if lhs.value.count == rhs.value.count { return lhs.key > rhs.key } + return lhs.value.count < rhs.value.count + } + let modeIpiMs = modalBin.map { SignalStatistics.mean($0.value) } + let amo50Percent = modalBin.map { + 100 * Double($0.value.count) / Double(values.count) + } + + let reliable = values.count >= configuration.minimumIntervalsForPRV + && rejectedRatio <= configuration.maximumRejectedIntervalRatioForPRV + && rmssd != nil + && sdnn != nil + + let metrics = PRVMetrics( + estimatedRmssdMs: rmssd, + estimatedSdnnMs: sdnn, + meanIpiMs: mean, + medianIpiMs: median, + pnn50Percent: pnn50Percent, + rangeIpiMs: rangeIpiMs, + modeIpiMs: modeIpiMs, + amo50Percent: amo50Percent, + coefficientOfVariation: coefficientOfVariation, + validIntervalCount: values.count, + rejectedIntervalCount: rejectedCount, + rejectedIntervalRatio: rejectedRatio, + reliable: reliable + ) + return PRVCalculationResult(metrics: metrics, intervals: intervals) + } + + public static func calculate( + intervalsMilliseconds: [Double], + configuration: PPGProcessingConfiguration + ) throws -> PRVMetrics { + var elapsed = 0.0 + let intervals = intervalsMilliseconds.enumerated().map { index, duration in + let start = elapsed + elapsed += duration / 1_000 + return PulseInterval( + sourceIndex: index, + startSeconds: start, + endSeconds: elapsed, + durationMilliseconds: duration + ) + } + return try calculate(intervals: intervals, configuration: configuration).metrics + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/PeakDetector.swift b/packages/react-native-haptika-ppg/ios/Signal/PeakDetector.swift new file mode 100644 index 0000000..68c740d --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/PeakDetector.swift @@ -0,0 +1,276 @@ +import Foundation + +public enum PPGPeakDetector { + public static func detect( + signal: [Double], + timestampsSeconds: [Double], + validityMask: [Bool], + configuration: PPGProcessingConfiguration + ) throws -> PeakDetectionResult { + let configuration = try configuration.validated() + guard signal.count == timestampsSeconds.count, + signal.count == validityMask.count, + signal.count >= 5 + else { + throw PPGSignalError.insufficientSamples(required: 5, actual: signal.count) + } + + let positive = detectPolarity( + signal: signal, + timestampsSeconds: timestampsSeconds, + validityMask: validityMask, + polarity: 1, + configuration: configuration + ) + let negative = detectPolarity( + signal: signal, + timestampsSeconds: timestampsSeconds, + validityMask: validityMask, + polarity: -1, + configuration: configuration + ) + + if negative.confidence > positive.confidence + 1e-12 { + return negative + } + if abs(negative.confidence - positive.confidence) <= 1e-12, + negative.acceptedIntervals.count > positive.acceptedIntervals.count { + return negative + } + return positive + } + + private struct Candidate { + var index: Int + var amplitude: Double + var prominence: Double + } + + private static func detectPolarity( + signal: [Double], + timestampsSeconds: [Double], + validityMask: [Bool], + polarity: Int, + configuration: PPGProcessingConfiguration + ) -> PeakDetectionResult { + let values = signal.map { ($0.isFinite ? $0 : 0) * Double(polarity) } + let timestampIntervals = zip(timestampsSeconds, timestampsSeconds.dropFirst()) + .map { $1 - $0 } + .filter { $0.isFinite && $0 > 0 } + let sampleInterval = SignalStatistics.median(timestampIntervals) + guard sampleInterval > 0 else { + return PeakDetectionResult(peaks: [], intervals: [], selectedPolarity: polarity, confidence: 0) + } + let sampleRate = 1 / sampleInterval + + let validValues = values.indices.compactMap { validityMask[$0] ? values[$0] : nil } + let center = SignalStatistics.median(validValues) + let robustScale = max(1.4826 * SignalStatistics.mad(validValues, around: center), 1e-6) + let amplitudeThreshold = center + max(configuration.minimumPeakAmplitude, robustScale * 0.18) + let prominenceRadius = max(Int((0.45 * sampleRate).rounded()), 2) + + var candidates: [Candidate] = [] + candidates.reserveCapacity(max(signal.count / 15, 8)) + for index in 1..<(values.count - 1) { + guard validityMask[index], + validityMask[index - 1], + validityMask[index + 1], + values[index] >= amplitudeThreshold, + values[index] > values[index - 1], + values[index] >= values[index + 1] + else { continue } + + let leftStart = max(0, index - prominenceRadius) + let rightEnd = min(values.count - 1, index + prominenceRadius) + let leftMinimum = values[leftStart..= configuration.minimumPeakProminence else { continue } + candidates.append(Candidate(index: index, amplitude: values[index], prominence: prominence)) + } + + let minimumDistance = max( + Int((configuration.refractoryPeriodSeconds * sampleRate).rounded()), + 2 + ) + var selected: [Candidate] = [] + for candidate in candidates { + if let last = selected.last, candidate.index - last.index < minimumDistance { + let candidateStrength = candidate.prominence + 0.25 * candidate.amplitude + let lastStrength = last.prominence + 0.25 * last.amplitude + if candidateStrength > lastStrength { + selected[selected.count - 1] = candidate + } + } else { + selected.append(candidate) + } + } + + let peaks = selected.map { candidate -> DetectedPeak in + let index = candidate.index + let left = values[index - 1] + let middle = values[index] + let right = values[index + 1] + let denominator = left - 2 * middle + right + let fractionalOffset: Double + if abs(denominator) > 1e-12 { + fractionalOffset = min(max(0.5 * (left - right) / denominator, -0.5), 0.5) + } else { + fractionalOffset = 0 + } + let refinedTimestamp = timestampsSeconds[index] + fractionalOffset * sampleInterval + return DetectedPeak( + sampleIndex: index, + timestampSeconds: refinedTimestamp, + amplitude: candidate.amplitude, + prominence: candidate.prominence + ) + } + + var intervals = zip(peaks, peaks.dropFirst()).enumerated().map { sourceIndex, pair in + let durationMilliseconds = (pair.1.timestampSeconds - pair.0.timestampSeconds) * 1_000 + let lowerIndex = min(pair.0.sampleIndex, pair.1.sampleIndex) + let upperIndex = max(pair.0.sampleIndex, pair.1.sampleIndex) + let crossesInvalidRegion = validityMask[lowerIndex...upperIndex].contains(false) + return PulseInterval( + sourceIndex: sourceIndex, + startSeconds: pair.0.timestampSeconds, + endSeconds: pair.1.timestampSeconds, + durationMilliseconds: durationMilliseconds, + rejectionReason: crossesInvalidRegion ? .invalidSignalRegion : nil + ) + } + + let minimumInterval = 1_000 / configuration.maximumFrequencyHz + let maximumInterval = 1_000 / configuration.minimumFrequencyHz + for index in intervals.indices where intervals[index].rejectionReason == nil { + let duration = intervals[index].durationMilliseconds + if !duration.isFinite || duration < minimumInterval || duration > maximumInterval { + intervals[index].rejectionReason = .outsidePhysiologicalRange + } + } + + // Score every pulse independently. A short movement or pressure change can + // therefore reject only the affected beats instead of invalidating the + // complete recording. Cycles are normalized to 50 points before their + // shape is compared with neighbouring cycles, following the published + // morphology-based quality approach used for camera PPG. + let cycleShapes = intervals.indices.map { index in + normalizedCycle( + values: values, + lowerIndex: peaks[index].sampleIndex, + upperIndex: peaks[index + 1].sampleIndex, + outputCount: 50 + ) + } + let medianProminence = max(SignalStatistics.median(peaks.map(\.prominence)), 1e-9) + for index in intervals.indices { + let lowerPeakQuality = SignalStatistics.clamp01(peaks[index].prominence / medianProminence) + let upperPeakQuality = SignalStatistics.clamp01(peaks[index + 1].prominence / medianProminence) + let prominenceQuality = min(lowerPeakQuality, upperPeakQuality) + let neighbourhood = max(0, index - 3)...min(intervals.count - 1, index + 3) + let correlations = neighbourhood.compactMap { neighbour -> Double? in + guard neighbour != index, + let shape = cycleShapes[index], + let other = cycleShapes[neighbour] + else { return nil } + return shapeCorrelation(shape, other) + } + let morphologyQuality: Double + if correlations.isEmpty { + morphologyQuality = intervals.count < 3 ? 0.75 : 0 + } else { + // Use the best two local matches so one adjacent corrupted cycle does + // not drag a clean beat below the acceptance threshold. + let strongest = correlations.sorted(by: >).prefix(2) + morphologyQuality = SignalStatistics.clamp01( + (SignalStatistics.mean(Array(strongest)) + 1) / 2 + ) + } + let technicalQuality = SignalStatistics.clamp01( + 0.65 * morphologyQuality + 0.35 * prominenceQuality + ) + intervals[index].qualityScore = technicalQuality + if intervals[index].rejectionReason == nil, + intervals.count >= 3, + technicalQuality < 0.42 { + intervals[index].rejectionReason = .lowMorphologyQuality + } + } + + let physiologicallyPlausible = intervals + .filter(\.isAccepted) + .map(\.durationMilliseconds) + if physiologicallyPlausible.count >= 3 { + let median = SignalStatistics.median(physiologicallyPlausible) + let robustDeviation = 1.4826 * SignalStatistics.mad(physiologicallyPlausible, around: median) + let allowedDeviation = max(0.28 * median, 3.5 * robustDeviation, 90) + for index in intervals.indices where intervals[index].rejectionReason == nil { + if abs(intervals[index].durationMilliseconds - median) > allowedDeviation { + intervals[index].rejectionReason = .localOutlier + } + } + } + + let accepted = intervals.filter(\.isAccepted) + let acceptedDurations = accepted.map(\.durationMilliseconds) + let intervalAcceptance = intervals.isEmpty + ? 0 + : Double(accepted.count) / Double(intervals.count) + let countScore = min(Double(accepted.count) / 8, 1) + let regularity: Double + if acceptedDurations.count >= 2 { + let median = max(SignalStatistics.median(acceptedDurations), 1) + let robustCV = 1.4826 * SignalStatistics.mad(acceptedDurations, around: median) / median + regularity = SignalStatistics.clamp01(1 - robustCV / 0.20) + } else { + regularity = 0 + } + let prominenceScore = SignalStatistics.clamp01(medianProminence / 1.5) + let validCoverage = Double(validityMask.filter { $0 }.count) / Double(max(validityMask.count, 1)) + let confidence = 0.27 * countScore + + 0.23 * intervalAcceptance + + 0.20 * regularity + + 0.20 * prominenceScore + + 0.10 * validCoverage + + return PeakDetectionResult( + peaks: peaks, + intervals: intervals, + selectedPolarity: polarity, + confidence: confidence + ) + } + + private static func normalizedCycle( + values: [Double], + lowerIndex: Int, + upperIndex: Int, + outputCount: Int + ) -> [Double]? { + guard lowerIndex >= 0, + upperIndex < values.count, + upperIndex - lowerIndex >= 3, + outputCount >= 3 + else { return nil } + let span = Double(upperIndex - lowerIndex) + let sampled = (0.. Double in + let position = Double(lowerIndex) + + Double(outputIndex) * span / Double(outputCount - 1) + let left = min(max(Int(position.rounded(.down)), lowerIndex), upperIndex) + let right = min(left + 1, upperIndex) + let fraction = position - Double(left) + return values[left] + fraction * (values[right] - values[left]) + } + let center = SignalStatistics.mean(sampled) + let scale = SignalStatistics.standardDeviation(sampled) + guard scale.isFinite, scale > 1e-8 else { return nil } + return sampled.map { ($0 - center) / scale } + } + + private static func shapeCorrelation(_ lhs: [Double], _ rhs: [Double]) -> Double? { + guard lhs.count == rhs.count, lhs.count >= 3 else { return nil } + let value = zip(lhs, rhs).reduce(0.0) { $0 + $1.0 * $1.1 } / Double(lhs.count) + return value.isFinite ? min(max(value, -1), 1) : nil + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/Resampler.swift b/packages/react-native-haptika-ppg/ios/Signal/Resampler.swift new file mode 100644 index 0000000..a08ab93 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/Resampler.swift @@ -0,0 +1,135 @@ +import Foundation + +public enum PPGSignalResampler { + /// Resamples irregular samples onto a uniform time grid using the real frame timestamps. + /// Values spanning a long gap are numerically interpolated to keep downstream filters finite, + /// but the corresponding validity mask remains false so no beat can be accepted there. + public static func resample( + _ samples: [TimestampedValue], + targetSampleRateHz: Double, + maximumInterpolationGapSeconds: Double + ) throws -> ResampledSignal { + guard targetSampleRateHz.isFinite, targetSampleRateHz > 0 else { + throw PPGSignalError.invalidConfiguration("targetSampleRateHz must be positive") + } + guard maximumInterpolationGapSeconds.isFinite, maximumInterpolationGapSeconds > 0 else { + throw PPGSignalError.invalidConfiguration("maximumInterpolationGapSeconds must be positive") + } + + let finite = samples.enumerated().compactMap { index, sample -> (Int, TimestampedValue)? in + guard sample.timestampSeconds.isFinite, sample.value.isFinite else { return nil } + return (index, sample) + } + guard finite.count >= 3 else { + throw PPGSignalError.insufficientSamples(required: 3, actual: finite.count) + } + + let outOfOrderCount = zip(finite, finite.dropFirst()).reduce(0) { count, pair in + count + (pair.1.1.timestampSeconds < pair.0.1.timestampSeconds ? 1 : 0) + } + let sorted = finite.sorted { + if $0.1.timestampSeconds == $1.1.timestampSeconds { return $0.0 < $1.0 } + return $0.1.timestampSeconds < $1.1.timestampSeconds + } + + var unique: [TimestampedValue] = [] + unique.reserveCapacity(sorted.count) + var duplicateTimestampCount = 0 + for (_, sample) in sorted { + if let last = unique.last, sample.timestampSeconds - last.timestampSeconds <= 1e-9 { + duplicateTimestampCount += 1 + continue + } + unique.append(sample) + } + guard unique.count >= 3 else { + throw PPGSignalError.invalidTimestamps + } + + let intervals = zip(unique, unique.dropFirst()).map { + $1.timestampSeconds - $0.timestampSeconds + } + guard intervals.allSatisfy({ $0.isFinite && $0 > 0 }) else { + throw PPGSignalError.invalidTimestamps + } + + let medianInterval = SignalStatistics.median(intervals) + guard medianInterval > 0 else { throw PPGSignalError.invalidTimestamps } + let intervalMAD = SignalStatistics.mad(intervals, around: medianInterval) + let maximumGap = intervals.max() ?? 0 + let duration = unique.last!.timestampSeconds - unique.first!.timestampSeconds + + var inferredMissingFrames = 0 + var longGapDuration = 0.0 + for interval in intervals { + if interval > medianInterval * 1.5 { + inferredMissingFrames += max(Int((interval / medianInterval).rounded()) - 1, 0) + } + if interval > maximumInterpolationGapSeconds { + longGapDuration += max(interval - medianInterval, 0) + } + } + let inferredTotalFrames = unique.count + inferredMissingFrames + let droppedFrameRatio = inferredTotalFrames > 0 + ? Double(inferredMissingFrames) / Double(inferredTotalFrames) + : 0 + let usableDuration = max(duration - longGapDuration, 0) + + let step = 1 / targetSampleRateHz + let outputCount = max(Int((duration * targetSampleRateHz).rounded(.down)) + 1, 1) + var timestamps: [Double] = [] + var values: [Double] = [] + var validityMask: [Bool] = [] + timestamps.reserveCapacity(outputCount) + values.reserveCapacity(outputCount) + validityMask.reserveCapacity(outputCount) + + var upperIndex = 1 + let firstTimestamp = unique[0].timestampSeconds + for outputIndex in 0.. 0 ? (timestamp - lower.timestampSeconds) / gap : 0 + let clampedFraction = min(max(fraction, 0), 1) + let value = lower.value + clampedFraction * (upper.value - lower.value) + let liesInsideSourceRange = timestamp >= lower.timestampSeconds - 1e-9 + && timestamp <= upper.timestampSeconds + 1e-9 + let isExactSourceSample = abs(timestamp - lower.timestampSeconds) <= step * 1e-6 + || abs(timestamp - upper.timestampSeconds) <= step * 1e-6 + let isValid = liesInsideSourceRange + && (isExactSourceSample || gap <= maximumInterpolationGapSeconds) + + timestamps.append(timestamp) + values.append(value.isFinite ? value : 0) + validityMask.append(isValid) + } + + let diagnostics = TimebaseDiagnostics( + inputSampleCount: samples.count, + usableInputSampleCount: unique.count, + discardedSampleCount: samples.count - finite.count, + duplicateTimestampCount: duplicateTimestampCount, + outOfOrderTimestampCount: outOfOrderCount, + durationSeconds: duration, + usableDurationSeconds: usableDuration, + estimatedSampleRateHz: 1 / medianInterval, + frameIntervalMADSeconds: intervalMAD, + droppedFrameRatio: droppedFrameRatio, + maximumGapSeconds: maximumGap, + longGapDurationSeconds: longGapDuration + ) + return ResampledSignal( + timestampsSeconds: timestamps, + values: values, + validityMask: validityMask, + diagnostics: diagnostics + ) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/SignalProcessor.swift b/packages/react-native-haptika-ppg/ios/Signal/SignalProcessor.swift new file mode 100644 index 0000000..5a02b41 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/SignalProcessor.swift @@ -0,0 +1,331 @@ +import Foundation + +public struct PPGSignalProcessor: Sendable { + public let configuration: PPGProcessingConfiguration + + public init(configuration: PPGProcessingConfiguration = .production) throws { + self.configuration = try configuration.validated() + } + + public func analyze(samples inputSamples: [PPGFrameSample]) throws -> PPGAnalysisResult { + let finiteSamples = inputSamples.filter(\.hasFiniteSignalValues) + let discardedInputCount = inputSamples.count - finiteSamples.count + guard finiteSamples.count >= 3 else { + throw PPGSignalError.insufficientSamples(required: 3, actual: finiteSamples.count) + } + let sortedTimestamps = finiteSamples.map(\.timestampSeconds).sorted() + guard let firstTimestamp = sortedTimestamps.first, + let lastTimestamp = sortedTimestamps.last, + lastTimestamp > firstTimestamp + else { + throw PPGSignalError.invalidTimestamps + } + let duration = lastTimestamp - firstTimestamp + guard duration >= configuration.minimumAnalysisDurationSeconds else { + throw PPGSignalError.insufficientDuration( + requiredSeconds: configuration.minimumAnalysisDurationSeconds, + actualSeconds: duration + ) + } + + let red = try resample(finiteSamples, value: \.red) + let green = try resample(finiteSamples, value: \.green) + let blue = try resample(finiteSamples, value: \.blue) + let luminance = try resample(finiteSamples, value: \.luminance) + let contact = try resample(finiteSamples, value: \.contactConfidence) + let saturation = try resample(finiteSamples, value: \.saturationRatio) + let motion = try resample(finiteSamples, value: \.motionArtifactScore) + let greenRedRatio = try PPGSignalResampler.resample( + finiteSamples.map { + let total = max($0.red + $0.green + $0.blue, 1) + return TimestampedValue( + timestampSeconds: $0.timestampSeconds, + value: $0.green / total + ) + }, + targetSampleRateHz: configuration.targetSampleRateHz, + maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds + ) + let blueRedRatio = try PPGSignalResampler.resample( + finiteSamples.map { + let total = max($0.red + $0.green + $0.blue, 1) + return TimestampedValue( + timestampSeconds: $0.timestampSeconds, + value: $0.blue / total + ) + }, + targetSampleRateHz: configuration.targetSampleRateHz, + maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds + ) + + guard red.values.count == green.values.count, + red.values.count == luminance.values.count, + red.values.count == contact.values.count, + red.values.count == saturation.values.count, + red.values.count == motion.values.count + else { + throw PPGSignalError.invalidTimestamps + } + + let blendValues = normalizedBlend( + red: red.values, + green: green.values, + blue: blue.values, + luminance: luminance.values + ) + let blendMask = zip(red.validityMask, zip(green.validityMask, luminance.validityMask)).map { + $0.0 && $0.1.0 && $0.1.1 + } + let blend = ResampledSignal( + timestampsSeconds: red.timestampsSeconds, + values: blendValues, + validityMask: blendMask, + diagnostics: red.diagnostics + ) + + let filter = try PPGBandPassFilter( + sampleRateHz: configuration.targetSampleRateHz, + lowCutoffHz: configuration.minimumFrequencyHz, + highCutoffHz: configuration.maximumFrequencyHz, + tapCount: configuration.filterTapCount + ) + let commonMetricMask = zip(contact.validityMask, zip(saturation.validityMask, motion.validityMask)) + .map { $0.0 && $0.1.0 && $0.1.1 } + // The coaching contact score must never decide whether a frame contains a + // pulse. Camera colour varies too much between front sensors and exposure + // modes. Only timestamp continuity and extreme optical corruption create + // invalid regions; periodicity and beat consistency decide quality later. + let physiologicalMask = contact.values.indices.map { index in + commonMetricMask[index] + && saturation.values[index] <= configuration.maximumSaturationRatio + && motion.values[index] <= configuration.maximumMotionArtifactScore + } + let guardedPhysiologicalMask = PPGSignalPreprocessor.dilateInvalidRegions( + physiologicalMask, + radius: max(Int((configuration.targetSampleRateHz * 0.20).rounded()), 1) + ) + + let sourceCandidates: [(PPGSignalChannel, ResampledSignal)] = [ + (.red, red), + (.green, green), + (.blue, blue), + (.luminance, luminance), + (.greenRedRatio, greenRedRatio), + (.blueRedRatio, blueRedRatio), + (.normalizedBlend, blend), + ] + let candidates = try sourceCandidates.map { channel, resampled in + try analyzeChannel( + channel: channel, + resampled: resampled, + guardedPhysiologicalMask: guardedPhysiologicalMask, + filter: filter + ) + } + guard var selected = candidates.max(by: channelCandidateIsWorse) else { + throw PPGSignalError.noUsableChannel + } + selected.timebase.inputSampleCount = inputSamples.count + selected.timebase.discardedSampleCount += discardedInputCount + + let prvCalculation = try PPGPRVCalculator.calculate( + intervals: selected.peakDetection.intervals, + configuration: configuration + ) + let validCoverage = Double(selected.validityMask.filter { $0 }.count) + / Double(max(selected.validityMask.count, 1)) + var qualityTimebase = selected.timebase + qualityTimebase.usableDurationSeconds = min( + qualityTimebase.usableDurationSeconds, + qualityTimebase.durationSeconds * validCoverage + ) + let quality = PPGSignalQualityEvaluator.evaluate( + SignalQualityInput( + timebase: qualityTimebase, + contactConfidence: contact.values, + saturationRatio: saturation.values, + motionArtifactScore: motion.values, + filteredSignal: selected.filteredSignal, + validityMask: selected.validityMask, + bpmEstimate: selected.bpm, + intervals: prvCalculation.intervals, + signalToNoiseRatioDB: selected.snrDB, + channelStabilityScore: selected.selectionScore + ), + configuration: configuration + ) + + // A camera PPG can provide a useful mean heart rate before it provides + // beat-to-beat timing good enough for PRV. Publish that conservative BPM + // when the evaluator did not mark the signal structurally invalid; keep + // PRV reliability gated below by the stricter quality/confidence checks. + // This mirrors how practical camera HR apps remain useful on borderline + // captures without turning them into HRV measurements. + let isValid = quality.level != .invalid + && quality.score >= PPGPublicationThresholds.bpmQualityScore + && selected.bpm.confidence >= PPGPublicationThresholds.bpmConfidence + && selected.bpm.bpm != nil + var publishedBPM = selected.bpm + var publishedPRV = prvCalculation.metrics + if !isValid { + // Keep independent estimates for diagnostics, but do not publish a definitive BPM. + publishedBPM.bpm = nil + publishedPRV.reliable = false + } else if quality.score < PPGPublicationThresholds.prvQualityScore + || selected.bpm.confidence < PPGPublicationThresholds.prvBpmConfidence { + // Beat-to-beat variability requires a stricter signal than mean pulse rate. + publishedPRV.reliable = false + } + + return PPGAnalysisResult( + status: isValid ? .valid : .invalid, + bpm: publishedBPM, + prv: publishedPRV, + quality: quality, + peaks: selected.peakDetection.peaks, + intervals: prvCalculation.intervals, + timebase: selected.timebase + ) + } + + private struct ChannelCandidate { + var channel: PPGSignalChannel + var filteredSignal: [Double] + var validityMask: [Bool] + var timebase: TimebaseDiagnostics + var peakDetection: PeakDetectionResult + var bpm: BPMEstimate + var snrDB: Double? + var selectionScore: Double + } + + private func analyzeChannel( + channel: PPGSignalChannel, + resampled: ResampledSignal, + guardedPhysiologicalMask: [Bool], + filter: PPGBandPassFilter + ) throws -> ChannelCandidate { + let prepared = PPGSignalPreprocessor.detrendAndNormalize( + resampled.values, + sampleRateHz: configuration.targetSampleRateHz + ) + let filtered = filter.apply(to: prepared) + let gapGuardedMask = PPGSignalPreprocessor.dilateInvalidRegions( + resampled.validityMask, + radius: filter.invalidRegionGuardRadius + ) + var validityMask = zip(gapGuardedMask, guardedPhysiologicalMask).map { $0.0 && $0.1 } + let edgeGuard = min(filter.invalidRegionGuardRadius, validityMask.count / 4) + if edgeGuard > 0 { + for index in 0.. + ) throws -> ResampledSignal { + try PPGSignalResampler.resample( + samples.map { + TimestampedValue(timestampSeconds: $0.timestampSeconds, value: $0[keyPath: keyPath]) + }, + targetSampleRateHz: configuration.targetSampleRateHz, + maximumInterpolationGapSeconds: configuration.maximumInterpolationGapSeconds + ) + } + + private func normalizedBlend( + red: [Double], + green: [Double], + blue: [Double], + luminance: [Double] + ) -> [Double] { + let standardizedRed = robustStandardize(red) + let standardizedGreen = robustStandardize(green) + let standardizedBlue = robustStandardize(blue) + let standardizedLuminance = robustStandardize(luminance) + return standardizedRed.indices.map { index in + // A compact chrominance projection suppresses common illumination and + // automatic-exposure changes while preserving blood-volume modulation. + 0.70 * standardizedGreen[index] + - 0.40 * standardizedRed[index] + - 0.30 * standardizedBlue[index] + + 0.10 * standardizedLuminance[index] + } + } + + private func robustStandardize(_ values: [Double]) -> [Double] { + let center = SignalStatistics.median(values) + let scale = max( + 1.4826 * SignalStatistics.mad(values, around: center), + SignalStatistics.standardDeviation(values) * 0.20, + 1e-9 + ) + return values.map { min(max(($0 - center) / scale, -12), 12) } + } + + private func channelCandidateIsWorse(_ lhs: ChannelCandidate, _ rhs: ChannelCandidate) -> Bool { + if abs(lhs.selectionScore - rhs.selectionScore) > 1e-12 { + return lhs.selectionScore < rhs.selectionScore + } + let lhsIndex = PPGSignalChannel.allCases.firstIndex(of: lhs.channel) ?? 0 + let rhsIndex = PPGSignalChannel.allCases.firstIndex(of: rhs.channel) ?? 0 + // Prefer the earlier, simpler channel when scores are exactly tied. + return lhsIndex > rhsIndex + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/SignalQualityEvaluator.swift b/packages/react-native-haptika-ppg/ios/Signal/SignalQualityEvaluator.swift new file mode 100644 index 0000000..f54f424 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/SignalQualityEvaluator.swift @@ -0,0 +1,176 @@ +import Foundation + +public struct SignalQualityInput: Equatable, Sendable { + public var timebase: TimebaseDiagnostics + public var contactConfidence: [Double] + public var saturationRatio: [Double] + public var motionArtifactScore: [Double] + public var filteredSignal: [Double] + public var validityMask: [Bool] + public var bpmEstimate: BPMEstimate + public var intervals: [PulseInterval] + public var signalToNoiseRatioDB: Double? + public var channelStabilityScore: Double + + public init( + timebase: TimebaseDiagnostics, + contactConfidence: [Double], + saturationRatio: [Double], + motionArtifactScore: [Double], + filteredSignal: [Double], + validityMask: [Bool], + bpmEstimate: BPMEstimate, + intervals: [PulseInterval], + signalToNoiseRatioDB: Double?, + channelStabilityScore: Double + ) { + self.timebase = timebase + self.contactConfidence = contactConfidence + self.saturationRatio = saturationRatio + self.motionArtifactScore = motionArtifactScore + self.filteredSignal = filteredSignal + self.validityMask = validityMask + self.bpmEstimate = bpmEstimate + self.intervals = intervals + self.signalToNoiseRatioDB = signalToNoiseRatioDB + self.channelStabilityScore = SignalStatistics.clamp01(channelStabilityScore) + } +} + +public enum PPGSignalQualityEvaluator { + public static func evaluate( + _ input: SignalQualityInput, + configuration: PPGProcessingConfiguration + ) -> SignalQualityResult { + let totalCount = max(input.validityMask.count, 1) + let validCoverage = Double(input.validityMask.filter { $0 }.count) / Double(totalCount) + let contactMean = boundedMean(input.contactConfidence) + let saturationMean = boundedMean(input.saturationRatio) + let motionMean = boundedMean(input.motionArtifactScore) + let motionFrameRatio = input.motionArtifactScore.isEmpty + ? 0 + : Double(input.motionArtifactScore.filter { $0 >= 0.50 }.count) + / Double(input.motionArtifactScore.count) + let motionArtifactRatio = max(motionMean, motionFrameRatio) + + let validSignal = input.filteredSignal.indices.compactMap { index -> Double? in + guard index < input.validityMask.count, + input.validityMask[index], + input.filteredSignal[index].isFinite + else { return nil } + return input.filteredSignal[index] + } + let pulsatileAmplitude = validSignal.isEmpty + ? 0 + : SignalStatistics.percentile(validSignal, 0.95) + - SignalStatistics.percentile(validSignal, 0.05) + let amplitudeScore = SignalStatistics.clamp01(pulsatileAmplitude / 2.0) + let snrScore = input.signalToNoiseRatioDB.map { + SignalStatistics.clamp01(($0 + 3) / 15) + } ?? 0 + + let sampleInterval = input.timebase.estimatedSampleRateHz > 0 + ? 1 / input.timebase.estimatedSampleRateHz + : 0 + let jitterRatio = sampleInterval > 0 + ? input.timebase.frameIntervalMADSeconds / sampleInterval + : 1 + let droppedFrameScore = SignalStatistics.clamp01(1 - input.timebase.droppedFrameRatio / 0.15) + let jitterScore = SignalStatistics.clamp01(1 - jitterRatio / 0.15) + let gapScore = input.timebase.maximumGapSeconds <= configuration.maximumInterpolationGapSeconds + ? 1.0 + : SignalStatistics.clamp01( + 1 - input.timebase.longGapDurationSeconds / max(input.timebase.durationSeconds, 1e-9) + ) + let timebaseScore = 0.45 * droppedFrameScore + 0.35 * jitterScore + 0.20 * gapScore + let durationScore = SignalStatistics.clamp01( + input.timebase.usableDurationSeconds / configuration.minimumAnalysisDurationSeconds + ) + let contactScore = SignalStatistics.clamp01(contactMean) + let saturationScore = SignalStatistics.clamp01(1 - saturationMean / 0.70) + let motionScore = SignalStatistics.clamp01(1 - motionArtifactRatio / 0.75) + let peakScore = input.bpmEstimate.confidence + let agreementScore = input.bpmEstimate.methodAgreementBpm.map { + SignalStatistics.clamp01(1 - $0 / max(configuration.maximumMethodDisagreementBPM, 1e-9)) + } ?? 0 + let rejectedIntervalRatio = input.intervals.isEmpty + ? 1 + : Double(input.intervals.filter { !$0.isAccepted }.count) / Double(input.intervals.count) + let intervalScore = SignalStatistics.clamp01(1 - rejectedIntervalRatio / 0.35) + + let score = 0.03 * contactScore + + 0.05 * saturationScore + + 0.06 * motionScore + + 0.14 * timebaseScore + + 0.12 * durationScore + + 0.08 * amplitudeScore + + 0.20 * snrScore + + 0.14 * peakScore + + 0.09 * agreementScore + + 0.05 * intervalScore + + 0.04 * input.channelStabilityScore + + var issues: [SignalQualityIssue] = [] + if input.timebase.usableDurationSeconds < configuration.minimumAnalysisDurationSeconds { + issues.append(.insufficientDuration) + } + if input.timebase.outOfOrderTimestampCount > 0 || input.timebase.duplicateTimestampCount > 0 { + issues.append(.invalidTimestamps) + } + if input.timebase.maximumGapSeconds > configuration.longGapLimitSeconds { + issues.append(.longInterruption) + } + if input.timebase.droppedFrameRatio > 0.12 { issues.append(.tooManyDroppedFrames) } + if jitterRatio > 0.12 { issues.append(.irregularTimestamps) } + if contactMean < 0.35 || validCoverage < 0.75 { issues.append(.poorFingerContact) } + if saturationMean > 0.60 { issues.append(.excessiveSaturation) } + if motionArtifactRatio > 0.55 { issues.append(.motionArtifacts) } + if pulsatileAmplitude < 0.75 { issues.append(.lowPulsatileAmplitude) } + if (input.signalToNoiseRatioDB ?? -.infinity) < 3 { issues.append(.lowSignalToNoise) } + if input.bpmEstimate.validPeakCount < 8 { issues.append(.tooFewPeaks) } + if input.bpmEstimate.bpm == nil { issues.append(.inconsistentBPMMethods) } + if rejectedIntervalRatio > 0.25 { issues.append(.tooManyRejectedIntervals) } + if input.channelStabilityScore < 0.45 { issues.append(.unstableChannel) } + if input.timebase.discardedSampleCount > 0 { issues.append(.nonFiniteInput) } + + let hardInvalid = input.bpmEstimate.bpm == nil + || input.signalToNoiseRatioDB == nil + || (input.signalToNoiseRatioDB ?? -.infinity) < 0 + || input.bpmEstimate.confidence < PPGPublicationThresholds.bpmConfidence + || input.timebase.maximumGapSeconds > configuration.longGapLimitSeconds + || validCoverage < 0.55 + || saturationMean > 0.92 + || motionArtifactRatio > 0.90 + || input.timebase.usableDurationSeconds < configuration.minimumAnalysisDurationSeconds * 0.80 + + let clampedScore = SignalStatistics.clamp01(score) + let level: SignalQualityLevel + if hardInvalid || clampedScore < PPGPublicationThresholds.bpmQualityScore { + level = .invalid + } else if clampedScore < configuration.minimumQualityScore { + level = .poor + } else if clampedScore < PPGPublicationThresholds.prvQualityScore { + level = .medium + } else { + level = .good + } + + return SignalQualityResult( + score: clampedScore, + level: level, + usableDurationSeconds: input.timebase.usableDurationSeconds, + droppedFrameRatio: input.timebase.droppedFrameRatio, + saturationRatio: saturationMean, + rejectedIntervalRatio: rejectedIntervalRatio, + signalToNoiseRatio: input.signalToNoiseRatioDB, + motionArtifactRatio: motionArtifactRatio, + methodAgreementScore: agreementScore, + issues: issues + ) + } + + private static func boundedMean(_ values: [Double]) -> Double { + let finite = values.filter(\.isFinite).map { min(max($0, 0), 1) } + return finite.isEmpty ? 0 : SignalStatistics.mean(finite) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Signal/Statistics.swift b/packages/react-native-haptika-ppg/ios/Signal/Statistics.swift new file mode 100644 index 0000000..8e7c21d --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Signal/Statistics.swift @@ -0,0 +1,196 @@ +import Foundation + +enum SignalStatistics { + static func mean(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + return values.reduce(0, +) / Double(values.count) + } + + static func median(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let middle = sorted.count / 2 + if sorted.count.isMultiple(of: 2) { + return (sorted[middle - 1] + sorted[middle]) / 2 + } + return sorted[middle] + } + + static func mad(_ values: [Double], around center: Double? = nil) -> Double { + guard !values.isEmpty else { return 0 } + let center = center ?? median(values) + return median(values.map { abs($0 - center) }) + } + + static func standardDeviation(_ values: [Double], sample: Bool = true) -> Double { + guard values.count > (sample ? 1 : 0) else { return 0 } + let average = mean(values) + let sumSquares = values.reduce(0) { partial, value in + let delta = value - average + return partial + delta * delta + } + let divisor = Double(values.count - (sample ? 1 : 0)) + return (sumSquares / divisor).squareRoot() + } + + static func rootMeanSquare(_ values: [Double]) -> Double { + guard !values.isEmpty else { return 0 } + let squareMean = values.reduce(0) { $0 + $1 * $1 } / Double(values.count) + return squareMean.squareRoot() + } + + static func percentile(_ values: [Double], _ probability: Double) -> Double { + guard !values.isEmpty else { return 0 } + let sorted = values.sorted() + let p = min(max(probability, 0), 1) + let position = p * Double(sorted.count - 1) + let lower = Int(position.rounded(.down)) + let upper = Int(position.rounded(.up)) + guard lower != upper else { return sorted[lower] } + let fraction = position - Double(lower) + return sorted[lower] + fraction * (sorted[upper] - sorted[lower]) + } + + static func clamp01(_ value: Double) -> Double { + guard value.isFinite else { return 0 } + return min(max(value, 0), 1) + } + + static func reflectedIndex(_ index: Int, count: Int) -> Int { + guard count > 1 else { return 0 } + var candidate = index + while candidate < 0 || candidate >= count { + if candidate < 0 { + candidate = -candidate - 1 + } + if candidate >= count { + candidate = 2 * count - candidate - 1 + } + } + return candidate + } + + /// Fits a sinusoid at `frequencyHz` and reports its power relative to residual power in dB. + static func sinusoidalSNRDB( + values: [Double], + timestamps: [Double], + validityMask: [Bool], + frequencyHz: Double + ) -> Double? { + guard values.count == timestamps.count, + values.count == validityMask.count, + frequencyHz.isFinite, + frequencyHz > 0 + else { return nil } + + let validIndices = values.indices.filter { + validityMask[$0] && values[$0].isFinite && timestamps[$0].isFinite + } + guard validIndices.count >= 12 else { return nil } + + let average = validIndices.reduce(0) { $0 + values[$1] } / Double(validIndices.count) + var sinSin = 0.0 + var cosCos = 0.0 + var sinCos = 0.0 + var valueSin = 0.0 + var valueCos = 0.0 + let origin = timestamps[validIndices[0]] + + for index in validIndices { + let angle = 2 * Double.pi * frequencyHz * (timestamps[index] - origin) + let sine = sin(angle) + let cosine = cos(angle) + let value = values[index] - average + sinSin += sine * sine + cosCos += cosine * cosine + sinCos += sine * cosine + valueSin += value * sine + valueCos += value * cosine + } + + let determinant = sinSin * cosCos - sinCos * sinCos + guard abs(determinant) > 1e-12 else { return nil } + let sineCoefficient = (valueSin * cosCos - valueCos * sinCos) / determinant + let cosineCoefficient = (valueCos * sinSin - valueSin * sinCos) / determinant + + var modeledPower = 0.0 + var residualPower = 0.0 + for index in validIndices { + let angle = 2 * Double.pi * frequencyHz * (timestamps[index] - origin) + let modeled = sineCoefficient * sin(angle) + cosineCoefficient * cos(angle) + let value = values[index] - average + modeledPower += modeled * modeled + let residual = value - modeled + residualPower += residual * residual + } + + guard modeledPower > 1e-12 else { return -60 } + let ratio = modeledPower / max(residualPower, 1e-12) + return 10 * log10(ratio) + } + + /// Compares average spectral power around the pulse fundamental (and its second harmonic) + /// with the remaining physiological band. The finite bandwidth tolerates slow BPM changes. + static func periodicBandSNRDB( + values: [Double], + timestamps: [Double], + validityMask: [Bool], + fundamentalFrequencyHz: Double, + minimumFrequencyHz: Double, + maximumFrequencyHz: Double + ) -> Double? { + guard values.count == timestamps.count, + values.count == validityMask.count, + values.count >= 30, + fundamentalFrequencyHz.isFinite, + fundamentalFrequencyHz > 0, + minimumFrequencyHz > 0, + maximumFrequencyHz > minimumFrequencyHz + else { return nil } + + let validIndices = values.indices.filter { + validityMask[$0] && values[$0].isFinite && timestamps[$0].isFinite + } + guard validIndices.count >= 30 else { return nil } + let origin = timestamps[validIndices[0]] + let duration = timestamps[validIndices.last!] - origin + guard duration > 2 else { return nil } + let average = validIndices.reduce(0) { $0 + values[$1] } / Double(validIndices.count) + let frequencyStep = max(1 / duration, 0.02) + let fundamentalHalfWidth = max(0.12, min(0.28, fundamentalFrequencyHz * 0.18)) + let secondHarmonic = 2 * fundamentalFrequencyHz + let harmonicHalfWidth = min(0.36, 1.5 * fundamentalHalfWidth) + + var signalPowers: [Double] = [] + var noisePowers: [Double] = [] + var frequency = minimumFrequencyHz + while frequency <= maximumFrequencyHz + frequencyStep * 0.5 { + var real = 0.0 + var imaginary = 0.0 + for index in validIndices { + let relativeTime = timestamps[index] - origin + let window = 0.5 - 0.5 * cos(2 * Double.pi * relativeTime / duration) + let centeredValue = (values[index] - average) * window + let angle = -2 * Double.pi * frequency * relativeTime + real += centeredValue * cos(angle) + imaginary += centeredValue * sin(angle) + } + let power = real * real + imaginary * imaginary + let nearFundamental = abs(frequency - fundamentalFrequencyHz) <= fundamentalHalfWidth + let nearSecondHarmonic = secondHarmonic <= maximumFrequencyHz + && abs(frequency - secondHarmonic) <= harmonicHalfWidth + if nearFundamental || nearSecondHarmonic { + signalPowers.append(power) + } else { + noisePowers.append(power) + } + frequency += frequencyStep + } + + guard !signalPowers.isEmpty, !noisePowers.isEmpty else { return nil } + let signalPower = mean(signalPowers) + let noisePower = mean(noisePowers) + guard signalPower > 1e-12 else { return -60 } + return 10 * log10(signalPower / max(noisePower, 1e-12)) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityModule.swift b/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityModule.swift new file mode 100644 index 0000000..3a1ae8d --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityModule.swift @@ -0,0 +1,65 @@ +import Foundation +import React + +/// Thin React Native facade over the central WCSession owner. The PPG and +/// Core Haptics objects remain Nitro modules; connectivity is intentionally a +/// process-wide service and therefore must not be instantiated per HybridObject. +@objc(HaptikaWatchConnectivity) +final class HaptikaWatchConnectivityModule: NSObject { + @objc static func requiresMainQueueSetup() -> Bool { false } + + @objc func start() { + HaptikaWatchConnectivityService.shared.activate() + } + + @objc func getConnectionState( + _ resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + let state = HaptikaWatchConnectivityService.shared.state() + resolve([ + "supported": state.supported, + "paired": state.paired, + "watchAppInstalled": state.installed, + "reachable": state.reachable, + "activationState": state.activationState, + "latestEventId": state.latestEventId ?? NSNull(), + "latestEventAt": state.latestEventAt ?? NSNull() + ]) + } + + @objc func drainEvents( + _ resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + resolve(HaptikaWatchConnectivityService.shared.drainEvents()) + } + + @objc func acknowledgeEvents(_ eventIds: [String]) { + HaptikaWatchConnectivityService.shared.acknowledge(eventIds: eventIds) + } + + @objc func sendPreferences( + _ json: String, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + resolve(HaptikaWatchConnectivityService.shared.sendPreferences(json: json)) + } + + @objc func sendCommand( + _ json: String, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + resolve(HaptikaWatchConnectivityService.shared.sendCommand(json: json)) + } + + @objc func transferEvent( + _ json: String, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + resolve(HaptikaWatchConnectivityService.shared.transferEvent(json: json)) + } +} diff --git a/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityModuleBridge.m b/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityModuleBridge.m new file mode 100644 index 0000000..1bdccb6 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityModuleBridge.m @@ -0,0 +1,21 @@ +#import + +@interface RCT_EXTERN_MODULE(HaptikaWatchConnectivity, NSObject) + +RCT_EXTERN_METHOD(start) +RCT_EXTERN_METHOD(getConnectionState:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(drainEvents:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(acknowledgeEvents:(NSArray *)eventIds) +RCT_EXTERN_METHOD(sendPreferences:(NSString *)json + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(sendCommand:(NSString *)json + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(transferEvent:(NSString *)json + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + +@end diff --git a/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityService.swift b/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityService.swift new file mode 100644 index 0000000..1fede63 --- /dev/null +++ b/packages/react-native-haptika-ppg/ios/Watch/HaptikaWatchConnectivityService.swift @@ -0,0 +1,490 @@ +import Foundation +import WatchConnectivity + +/// The only owner of `WCSession` in the iPhone process. +/// +/// Durable Watch envelopes are persisted before being exposed to JavaScript. +/// JavaScript explicitly acknowledges imported event IDs, after which the +/// service removes them and sends an acknowledgement to the paired Watch. +final class HaptikaWatchConnectivityService: NSObject, WCSessionDelegate { + static let shared = HaptikaWatchConnectivityService() + + struct ConnectionState { + let supported: Bool + let paired: Bool + let installed: Bool + let reachable: Bool + let activationState: String + let latestEventId: String? + let latestEventAt: String? + } + + struct LiveHeartRate { + let bpm: Double + let timestampSeconds: Double + } + + private static let inboxKey = "HaptikaWatchConnectivity.inbox.v1" + private let lock = NSLock() + private let defaults = UserDefaults.standard + private var memoryLiveEvents: [String] = [] + private var latestEventIdValue: String? + private var latestEventAtValue: String? + private var latestHeartRateValue: LiveHeartRate? + + private override init() { + super.init() + } + + func activate() { + guard WCSession.isSupported() else { return } + let session = WCSession.default + if session.delegate !== self { + session.delegate = self + } + if session.activationState != .activated { + session.activate() + } + } + + func state() -> ConnectionState { + guard WCSession.isSupported() else { + return ConnectionState(supported: false, paired: false, installed: false, reachable: false, activationState: "notActivated", latestEventId: nil, latestEventAt: nil) + } + let session = WCSession.default + lock.lock() + let eventId = latestEventIdValue + let eventAt = latestEventAtValue + lock.unlock() + let activation: String + switch session.activationState { + case .activated: activation = "activated" + case .inactive: activation = "inactive" + case .notActivated: activation = "notActivated" + @unknown default: activation = "notActivated" + } + return ConnectionState( + supported: true, + paired: session.isPaired, + installed: session.isWatchAppInstalled, + reachable: session.isReachable, + activationState: activation, + latestEventId: eventId, + latestEventAt: eventAt + ) + } + + func latestHeartRate() -> LiveHeartRate? { + lock.lock() + defer { lock.unlock() } + return latestHeartRateValue + } + + func drainEvents() -> [String] { + lock.lock() + defer { lock.unlock() } + let durable = defaults.stringArray(forKey: Self.inboxKey) ?? [] + return durable + memoryLiveEvents + } + + func acknowledge(eventIds: [String]) { + guard !eventIds.isEmpty else { return } + let identifiers = Set(eventIds) + lock.lock() + let durable = defaults.stringArray(forKey: Self.inboxKey) ?? [] + defaults.set(durable.filter { json in + guard let eventId = Self.eventId(in: json) else { return false } + return !identifiers.contains(eventId) + }, forKey: Self.inboxKey) + memoryLiveEvents.removeAll { json in + guard let eventId = Self.eventId(in: json) else { return true } + return identifiers.contains(eventId) + } + lock.unlock() + + sendAcknowledgement(eventIds: eventIds) + } + + func sendPreferences(json: String) -> Bool { + guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false } + let rawPayload = (dictionary["payload"] as? [String: Any]) ?? dictionary + let save = (rawPayload["saveMindAndBodyWorkouts"] as? Bool) ?? true + let catalogVersion = (rawPayload["protocolCatalogVersion"] as? String) ?? "1.0.0" + let preferences: [String: Any] = [ + "schemaVersion": 1, + "saveMindAndBodyWorkouts": save, + "protocolCatalogVersion": catalogVersion, + "updatedAt": (rawPayload["updatedAt"] as? String) ?? Self.iso8601.string(from: Date()) + ] + guard let data = try? JSONSerialization.data(withJSONObject: preferences, options: [.sortedKeys]) else { return false } + activate() + do { + try WCSession.default.updateApplicationContext(["preferences": data]) + return true + } catch { + return false + } + } + + func sendCommand(json: String) -> Bool { + guard let dictionary = Self.dictionary(from: json), WCSession.isSupported() else { return false } + activate() + guard WCSession.default.isReachable else { return false } + let payload = (dictionary["payload"] as? [String: Any]) ?? dictionary + guard let envelope = Self.watchWireEnvelope(kind: "mirrorCommand", payload: payload), + let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false } + WCSession.default.sendMessageData(encoded, replyHandler: nil, errorHandler: nil) + return true + } + + func transferEvent(json: String) -> Bool { + guard let dictionary = Self.dictionary(from: json), WCSession.isSupported(), + let kind = dictionary["kind"] as? String, + let payloadObject = dictionary["payload"], + JSONSerialization.isValidJSONObject(payloadObject), + let envelope = Self.watchWireEnvelope(kind: kind, payload: payloadObject), + let encoded = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return false } + activate() + WCSession.default.transferUserInfo(["envelope": encoded]) + return true + } + + private func receive(_ dictionary: [String: Any], durable: Bool) { + guard let normalized = normalize(dictionary), + let data = try? JSONSerialization.data(withJSONObject: normalized, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) else { return } + let kind = normalized["kind"] as? String + let eventId = normalized["eventId"] as? String + let sentAt = normalized["sentAt"] as? String + + lock.lock() + latestEventIdValue = eventId + latestEventAtValue = sentAt + if kind == "liveHeartRate", let payload = normalized["payload"] as? [String: Any], + let bpm = Self.double(payload["bpm"]), bpm >= 35, bpm <= 220, + let timestamp = Self.double(payload["timestampSeconds"]), timestamp.isFinite { + latestHeartRateValue = LiveHeartRate(bpm: bpm, timestampSeconds: timestamp) + } + if durable { + var inbox = defaults.stringArray(forKey: Self.inboxKey) ?? [] + if eventId == nil || !inbox.contains(where: { Self.eventId(in: $0) == eventId }) { + inbox.append(json) + defaults.set(Array(inbox.suffix(500)), forKey: Self.inboxKey) + } + } else { + memoryLiveEvents.append(json) + if memoryLiveEvents.count > 60 { memoryLiveEvents.removeFirst(memoryLiveEvents.count - 60) } + } + lock.unlock() + } + + private func normalize(_ dictionary: [String: Any]) -> [String: Any]? { + if dictionary["kind"] != nil, + dictionary["eventId"] != nil || dictionary["eventID"] != nil { + return normalizeEnvelopeDictionary(dictionary) + } + // Compatibility with the early prototype. It remains a live HR sample and + // is never treated as a biological heartbeat. + guard let bpm = Self.double(dictionary["bpm"]), bpm >= 35, bpm <= 220, + let timestamp = Self.double(dictionary["timestampSeconds"]) else { return nil } + return [ + "schemaVersion": 1, + "eventId": "legacy-live-\(UUID().uuidString)", + "deviceId": "legacy-watch", + "sequence": 0, + "sentAt": Self.iso8601.string(from: Date()), + "kind": "liveHeartRate", + "payload": ["bpm": bpm, "timestampSeconds": timestamp] + ] + } + + private func normalizeEnvelopeDictionary(_ dictionary: [String: Any]) -> [String: Any]? { + let eventId = (dictionary["eventId"] ?? dictionary["eventID"]) as? String + let deviceId = (dictionary["deviceId"] ?? dictionary["deviceID"]) as? String + guard let eventId, let deviceId, let kindValue = dictionary["kind"] as? String else { return nil } + let kind = kindValue == "acknowledgement" ? "ack" : kindValue + let sentAt = (dictionary["sentAt"] as? String) ?? Self.iso8601.string(from: Date()) + let sequence = Self.double(dictionary["sequence"]) ?? 0 + let payloadObject: Any + if let payload = dictionary["payload"] as? [String: Any] { + payloadObject = payload + } else if let base64 = dictionary["payload"] as? String, + let data = Data(base64Encoded: base64), + let decoded = try? JSONSerialization.jsonObject(with: data) { + payloadObject = decoded + } else if let data = dictionary["payload"] as? Data, + let decoded = try? JSONSerialization.jsonObject(with: data) { + payloadObject = decoded + } else { + payloadObject = NSNull() + } + let normalizedPayload = normalizePayload(payloadObject, kind: kind, deviceId: deviceId, sentAt: sentAt) + return [ + "schemaVersion": 1, + "eventId": eventId, + "deviceId": deviceId, + "sequence": sequence, + "sentAt": sentAt, + "kind": kind, + "payload": normalizedPayload + ] + } + + private func normalizePayload(_ payload: Any, kind: String, deviceId: String, sentAt: String) -> Any { + guard let value = payload as? [String: Any] else { return payload } + // Current watchOS builds encode the exact cross-platform V1 DTO. Do not + // reinterpret canonical fields such as `coverage`, `currentBpm` or the + // compact sample offsets. + if Self.double(value["schemaVersion"]) == 1 { + if kind == "measurementUpsert" { + var canonical = value + for key in ["currentBpm", "averageBpm", "minimumBpm", "maximumBpm", "invalidReason", "feeling"] where canonical[key] == nil { + canonical[key] = NSNull() + } + if var device = canonical["device"] as? [String: Any] { + if device["model"] == nil { device["model"] = NSNull() } + if device["systemVersion"] == nil { device["systemVersion"] = NSNull() } + canonical["device"] = device + } + if var context = canonical["context"] as? [String: Any] { + if context["posture"] == nil { context["posture"] = NSNull() } + for key in ["recentExercise", "recentCaffeine", "recentMeal"] where context[key] == nil { + context[key] = NSNull() + } + canonical["context"] = context + } + return canonical + } + if kind == "regulationSessionUpsert" { + var canonical = value + for key in ["beforeMeasurementId", "afterMeasurementId", "endedAt", "workoutUUID", "feelingBefore", "feelingAfter"] where canonical[key] == nil { + canonical[key] = NSNull() + } + return canonical + } + if ["tombstone", "preferences"].contains(kind) { return value } + } + switch kind { + case "measurementUpsert": + guard let id = value["id"] as? String, + let startedAt = value["startedAt"] as? String, + let endedAt = value["endedAt"] as? String else { return value } + let updatedAt = (value["updatedAt"] as? String) ?? sentAt + let startedSeconds = Self.timestamp(startedAt) ?? 0 + let duration = max(1, (Self.timestamp(endedAt) ?? startedSeconds + 60) - startedSeconds) + let coverageSeconds = Self.double(value["coverageSeconds"]) ?? 0 + let rawSamples = value["samples"] as? [[String: Any]] ?? [] + let samples: [[String: Any]] = rawSamples.compactMap { sample in + guard let bpm = Self.double(sample["bpm"]), + let date = sample["date"] as? String, + let sampleTimestamp = Self.timestamp(date) else { return nil } + return ["offsetSeconds": max(0, sampleTimestamp - startedSeconds), "bpm": bpm] + } + let context = value["context"] as? [String: Any] ?? [:] + let postureValue = context["posture"] as? String + let posture: Any = ["seated", "lying", "standing"].contains(postureValue ?? "") + ? Self.jsonString(postureValue) + : NSNull() + let reasons = value["invalidReasons"] as? [String] ?? [] + let feeling: Any + if let score = Self.double(value["feeling"]), (1 ... 5).contains(score) { + feeling = ["score": Int(score), "recordedAt": updatedAt] + } else { + feeling = NSNull() + } + let devicePayload: [String: Any] = [ + "id": deviceId, + "name": (value["deviceName"] as? String) ?? "Apple Watch", + "model": Self.jsonString(value["deviceModel"]), + "systemVersion": NSNull() + ] + let contextPayload: [String: Any] = [ + "atRest": (context["isAtRest"] as? Bool) ?? true, + "posture": posture, + "recentExercise": (context["recentExercise"] as? Bool) ?? false, + "recentCaffeine": (context["recentCaffeine"] as? Bool) ?? false, + "recentMeal": (context["recentMeal"] as? Bool) ?? false + ] + let normalizedCoverage = min(1.0, max(0.0, coverageSeconds / duration)) + let normalizedQuality = min(1.0, max(0.0, Self.double(value["quality"]) ?? 0.0)) + let invalidReason: Any = reasons.isEmpty ? NSNull() : reasons.joined(separator: ",") + + var normalized: [String: Any] = [:] + normalized["schemaVersion"] = 1 + normalized["id"] = id + normalized["updatedAt"] = updatedAt + normalized["startedAt"] = startedAt + normalized["endedAt"] = endedAt + normalized["source"] = "appleWatchOptical" + normalized["device"] = devicePayload + normalized["currentBpm"] = Self.jsonNumber(value["currentBPM"]) + normalized["averageBpm"] = Self.jsonNumber(value["averageBPM"]) + normalized["minimumBpm"] = Self.jsonNumber(value["minimumBPM"]) + normalized["maximumBpm"] = Self.jsonNumber(value["maximumBPM"]) + normalized["samples"] = samples + normalized["coverage"] = normalizedCoverage + normalized["quality"] = normalizedQuality + normalized["valid"] = (value["isValid"] as? Bool) ?? false + normalized["invalidReason"] = invalidReason + normalized["context"] = contextPayload + normalized["feeling"] = feeling + return normalized + case "regulationSessionUpsert": + let sourceState = value["state"] as? String + let normalizedState = sourceState == "interrupted" ? "cancelled" : (sourceState ?? "cancelled") + var normalized: [String: Any] = [:] + normalized["schemaVersion"] = 1 + normalized["id"] = value["id"] as? String ?? "" + normalized["updatedAt"] = value["updatedAt"] as? String ?? sentAt + normalized["protocolId"] = value["protocolID"] as? String ?? "" + normalized["beforeMeasurementId"] = value["beforeMeasurementID"] ?? NSNull() + normalized["afterMeasurementId"] = value["afterMeasurementID"] ?? NSNull() + normalized["startedAt"] = value["startedAt"] as? String ?? sentAt + normalized["endedAt"] = value["endedAt"] ?? NSNull() + normalized["durationSeconds"] = Self.double(value["durationSeconds"]) ?? 0.0 + normalized["state"] = normalizedState + normalized["workoutUUID"] = value["healthKitWorkoutID"] ?? NSNull() + normalized["feelingBefore"] = value["feelingBefore"] ?? NSNull() + normalized["feelingAfter"] = value["feelingAfter"] ?? NSNull() + return normalized + case "tombstone": + let deletedAt = value["deletedAt"] as? String ?? sentAt + return [ + "schemaVersion": 1, + "entityId": (value["entityId"] ?? value["entityID"]) as? String ?? "", + "entityKind": value["entityKind"] as? String ?? "measurement", + "deletedAt": deletedAt, + "updatedAt": deletedAt + ] + case "liveHeartRate": + let sampledAt = value["sampledAt"] as? String + return [ + "bpm": Self.double(value["bpm"]) ?? 0, + "timestampSeconds": sampledAt.flatMap(Self.timestamp) ?? Date().timeIntervalSince1970, + "measurementId": value["measurementID"] ?? NSNull() + ] + default: + return value + } + } + + private func sendAcknowledgement(eventIds: [String]) { + guard WCSession.isSupported() else { return } + activate() + let validEventIds = eventIds.compactMap(UUID.init(uuidString:)).map(\.uuidString) + guard !validEventIds.isEmpty else { return } + let payload: [String: Any] = ["eventIDs": validEventIds, "receivedAt": Self.iso8601.string(from: Date())] + guard let envelope = Self.watchWireEnvelope(kind: "ack", payload: payload), + let envelopeData = try? JSONSerialization.data(withJSONObject: envelope, options: [.sortedKeys]) else { return } + if WCSession.default.isReachable { + WCSession.default.sendMessageData(envelopeData, replyHandler: nil) { _ in + WCSession.default.transferUserInfo(["envelope": envelopeData]) + } + } else { + WCSession.default.transferUserInfo(["envelope": envelopeData]) + } + } + + func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {} + func sessionDidBecomeInactive(_ session: WCSession) {} + func sessionDidDeactivate(_ session: WCSession) { session.activate() } + func sessionReachabilityDidChange(_ session: WCSession) {} + func sessionWatchStateDidChange(_ session: WCSession) {} + + func session(_ session: WCSession, didReceiveMessage message: [String: Any]) { + receive(message, durable: (message["kind"] as? String) != "liveHeartRate") + } + + func session(_ session: WCSession, didReceiveMessageData messageData: Data) { + receiveEnvelopeData(messageData, durable: false) + } + + func session(_ session: WCSession, didReceiveMessageData messageData: Data, replyHandler: @escaping (Data) -> Void) { + receiveEnvelopeData(messageData, durable: false) + replyHandler(Data()) + } + + func session(_ session: WCSession, didReceiveMessage message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void) { + receive(message, durable: (message["kind"] as? String) != "liveHeartRate") + replyHandler(["accepted": true]) + } + + func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) { + if let envelope = userInfo["envelope"] as? Data { + receiveEnvelopeData(envelope, durable: true) + } else { + receive(userInfo, durable: true) + } + } + + func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) { + receive(applicationContext, durable: true) + } + + private static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static func dictionary(from json: String) -> [String: Any]? { + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any], + JSONSerialization.isValidJSONObject(dictionary) else { return nil } + return dictionary + } + + private static func eventId(in json: String) -> String? { + return dictionary(from: json)?["eventId"] as? String + } + + private static func double(_ value: Any?) -> Double? { + if let number = value as? NSNumber { return number.doubleValue } + return value as? Double + } + + private func receiveEnvelopeData(_ data: Data, durable: Bool) { + guard let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] else { return } + receive(dictionary, durable: durable || (dictionary["kind"] as? String) != "liveHeartRate") + } + + private static func timestamp(_ value: String) -> TimeInterval? { + if let date = iso8601.date(from: value) { return date.timeIntervalSince1970 } + let fallback = ISO8601DateFormatter() + fallback.formatOptions = [.withInternetDateTime] + return fallback.date(from: value)?.timeIntervalSince1970 + } + + private static func jsonNumber(_ value: Any?) -> Any { + return double(value).map { NSNumber(value: $0) } ?? NSNull() + } + + private static func jsonString(_ value: Any?) -> Any { + return (value as? String).map { $0 as Any } ?? NSNull() + } + + private static func watchWireEnvelope(kind: String, payload: Any) -> [String: Any]? { + guard JSONSerialization.isValidJSONObject(payload) else { return nil } + return [ + "schemaVersion": 1, + "eventId": UUID().uuidString, + "deviceId": UIDeviceIdentifier.current, + "sequence": 0, + "sentAt": iso8601.string(from: Date()), + "kind": kind, + "payload": payload + ] + } +} + +private enum UIDeviceIdentifier { + static let current: String = { + let key = "HaptikaWatchConnectivity.deviceId.v1" + if let existing = UserDefaults.standard.string(forKey: key) { return existing } + let generated = "iphone-\(UUID().uuidString)" + UserDefaults.standard.set(generated, forKey: key) + return generated + }() +} diff --git a/packages/react-native-haptika-ppg/nitro.json b/packages/react-native-haptika-ppg/nitro.json new file mode 100644 index 0000000..564c7d6 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitro.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://nitro.margelo.com/nitro.schema.json", + "cxxNamespace": ["haptika", "ppg"], + "ios": { + "iosModuleName": "HaptikaPPG" + }, + "android": { + "androidNamespace": ["haptika", "ppg"], + "androidCxxLibName": "HaptikaPPG" + }, + "autolinking": { + "HaptikaPPGFrameProcessor": { + "ios": { + "language": "swift", + "implementationClassName": "HybridHaptikaPPGFrameProcessor" + } + }, + "HaptikaHealthKit": { + "ios": { + "language": "swift", + "implementationClassName": "HybridHaptikaHealthKit" + } + } + }, + "ignorePaths": ["**/node_modules"] +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/.gitattributes b/packages/react-native-haptika-ppg/nitrogen/generated/.gitattributes new file mode 100644 index 0000000..fb7a0d5 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/.gitattributes @@ -0,0 +1 @@ +** linguist-generated=true diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG+autolinking.rb b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG+autolinking.rb new file mode 100644 index 0000000..8bf0e30 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG+autolinking.rb @@ -0,0 +1,62 @@ +# +# HaptikaPPG+autolinking.rb +# This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +# https://github.com/mrousavy/nitro +# Copyright © Marc Rousavy @ Margelo +# + +# This is a Ruby script that adds all files generated by Nitrogen +# to the given podspec. +# +# To use it, add this to your .podspec: +# ```ruby +# Pod::Spec.new do |spec| +# # ... +# +# # Add all files generated by Nitrogen +# load 'nitrogen/generated/ios/HaptikaPPG+autolinking.rb' +# add_nitrogen_files(spec) +# end +# ``` + +def add_nitrogen_files(spec) + Pod::UI.puts "[NitroModules] 🔥 HaptikaPPG is boosted by nitro!" + + spec.dependency "NitroModules" + + current_source_files = Array(spec.attributes_hash['source_files']) + spec.source_files = current_source_files + [ + # Generated cross-platform specs + "nitrogen/generated/shared/**/*.{h,hpp,c,cpp,swift}", + # Generated bridges for the cross-platform specs + "nitrogen/generated/ios/**/*.{h,hpp,c,cpp,mm,swift}", + ] + + current_public_header_files = Array(spec.attributes_hash['public_header_files']) + spec.public_header_files = current_public_header_files + [ + # Generated specs + "nitrogen/generated/shared/**/*.{h,hpp}", + # Swift to C++ bridging helpers + "nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.hpp" + ] + + current_private_header_files = Array(spec.attributes_hash['private_header_files']) + spec.private_header_files = current_private_header_files + [ + # iOS specific specs + "nitrogen/generated/ios/c++/**/*.{h,hpp}", + # Views are framework-specific and should be private + "nitrogen/generated/shared/**/views/**/*" + ] + + current_pod_target_xcconfig = spec.attributes_hash['pod_target_xcconfig'] || {} + spec.pod_target_xcconfig = current_pod_target_xcconfig.merge({ + # Use C++ 20 + "CLANG_CXX_LANGUAGE_STANDARD" => "c++20", + # Enables C++ <-> Swift interop (by default it's only ObjC) + "SWIFT_OBJC_INTEROP_MODE" => "objcxx", + # Enables stricter modular headers + "DEFINES_MODULE" => "YES", + # Disable auto-generated ObjC header for Swift (Static linkage on Xcode 26.4 breaks here) + "SWIFT_INSTALL_OBJC_HEADER" => "NO", + }) +end diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.cpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.cpp new file mode 100644 index 0000000..9d1ba07 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.cpp @@ -0,0 +1,110 @@ +/// +/// HaptikaPPG-Swift-Cxx-Bridge.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HaptikaPPG-Swift-Cxx-Bridge.hpp" + +// Include C++ implementation defined types +#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp" +#include "HybridHaptikaCardiacMirrorSpecSwift.hpp" +#include "HybridHaptikaHealthKitSpecSwift.hpp" +#include "HybridHaptikaPPGFrameProcessorSpecSwift.hpp" +#include +#include + +namespace margelo::nitro::haptika::ppg::bridge::swift { + + // pragma MARK: std::shared_ptr + std::shared_ptr create_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(void* NON_NULL swiftUnsafePointer) noexcept { + // Implemented in VisionCamera + return margelo::nitro::camera::bridge::swift::create_std__shared_ptr_HybridFrameSpec_(swiftUnsafePointer); + } + void* NON_NULL get_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_ cppType) { + // Implemented in VisionCamera + return margelo::nitro::camera::bridge::swift::get_std__shared_ptr_HybridFrameSpec_(cppType); + } + + // pragma MARK: std::shared_ptr + std::shared_ptr create_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(void* NON_NULL swiftUnsafePointer) noexcept { + HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx swiftPart = HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx::fromUnsafe(swiftUnsafePointer); + return std::make_shared(swiftPart); + } + void* NON_NULL get_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ cppType) { + std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); + #ifdef NITRO_DEBUG + if (swiftWrapper == nullptr) [[unlikely]] { + throw std::runtime_error("Class \"HybridHaptikaPPGFrameProcessorSpec\" is not implemented in Swift!"); + } + #endif + HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx& swiftPart = swiftWrapper->getSwiftPart(); + return swiftPart.toUnsafe(); + } + + // pragma MARK: std::function + Func_void_bool create_Func_void_bool(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = HaptikaPPG::Func_void_bool::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](bool result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::function + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = HaptikaPPG::Func_void_std__exception_ptr::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const std::exception_ptr& error) mutable -> void { + swiftClosure.call(error); + }; + } + + // pragma MARK: std::function + Func_void_NativeHealthKitContext create_Func_void_NativeHealthKitContext(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = HaptikaPPG::Func_void_NativeHealthKitContext::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const NativeHealthKitContext& result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::function& /* result */)> + Func_void_std__optional_NativeHeartbeatSeriesSummary_ create_Func_void_std__optional_NativeHeartbeatSeriesSummary_(void* NON_NULL swiftClosureWrapper) noexcept { + auto swiftClosure = HaptikaPPG::Func_void_std__optional_NativeHeartbeatSeriesSummary_::fromUnsafe(swiftClosureWrapper); + return [swiftClosure = std::move(swiftClosure)](const std::optional& result) mutable -> void { + swiftClosure.call(result); + }; + } + + // pragma MARK: std::shared_ptr + std::shared_ptr create_std__shared_ptr_HybridHaptikaHealthKitSpec_(void* NON_NULL swiftUnsafePointer) noexcept { + HaptikaPPG::HybridHaptikaHealthKitSpec_cxx swiftPart = HaptikaPPG::HybridHaptikaHealthKitSpec_cxx::fromUnsafe(swiftUnsafePointer); + return std::make_shared(swiftPart); + } + void* NON_NULL get_std__shared_ptr_HybridHaptikaHealthKitSpec_(std__shared_ptr_HybridHaptikaHealthKitSpec_ cppType) { + std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); + #ifdef NITRO_DEBUG + if (swiftWrapper == nullptr) [[unlikely]] { + throw std::runtime_error("Class \"HybridHaptikaHealthKitSpec\" is not implemented in Swift!"); + } + #endif + HaptikaPPG::HybridHaptikaHealthKitSpec_cxx& swiftPart = swiftWrapper->getSwiftPart(); + return swiftPart.toUnsafe(); + } + + // pragma MARK: std::shared_ptr + std::shared_ptr create_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(void* NON_NULL swiftUnsafePointer) noexcept { + HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx swiftPart = HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx::fromUnsafe(swiftUnsafePointer); + return std::make_shared(swiftPart); + } + void* NON_NULL get_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ cppType) { + std::shared_ptr swiftWrapper = std::dynamic_pointer_cast(cppType); + #ifdef NITRO_DEBUG + if (swiftWrapper == nullptr) [[unlikely]] { + throw std::runtime_error("Class \"HybridHaptikaCardiacMirrorSpec\" is not implemented in Swift!"); + } + #endif + HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx& swiftPart = swiftWrapper->getSwiftPart(); + return swiftPart.toUnsafe(); + } + +} // namespace margelo::nitro::haptika::ppg::bridge::swift diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.hpp new file mode 100644 index 0000000..9769c7b --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Bridge.hpp @@ -0,0 +1,489 @@ +/// +/// HaptikaPPG-Swift-Cxx-Bridge.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `FingerContactState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class FingerContactState; } +// Forward declaration of `HealthMetricType` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class HealthMetricType; } +// Forward declaration of `HybridFrameSpec` to properly resolve imports. +namespace margelo::nitro::camera { class HybridFrameSpec; } +// Forward declaration of `HybridHaptikaCardiacMirrorSpec` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { class HybridHaptikaCardiacMirrorSpec; } +// Forward declaration of `HybridHaptikaHealthKitSpec` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { class HybridHaptikaHealthKitSpec; } +// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { class HybridHaptikaPPGFrameProcessorSpec; } +// Forward declaration of `NativeAcceptedBeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeAcceptedBeat; } +// Forward declaration of `NativeCardiacMirrorHeartbeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorHeartbeat; } +// Forward declaration of `NativeCardiacMirrorPatternInfo` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorPatternInfo; } +// Forward declaration of `NativeHealthKitContext` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthKitContext; } +// Forward declaration of `NativeHealthMetricSample` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthMetricSample; } +// Forward declaration of `NativeHeartbeatSeriesSummary` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHeartbeatSeriesSummary; } +// Forward declaration of `NativePPGProcessingResult` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePPGProcessingResult; } +// Forward declaration of `PPGFrameUpdate` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct PPGFrameUpdate; } +// Forward declaration of `ThermalState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class ThermalState; } + +// Forward declarations of Swift defined types +// Forward declaration of `HybridFrameSpec_cxx` to properly resolve imports. +namespace VisionCamera { class HybridFrameSpec_cxx; } +// Forward declaration of `HybridHaptikaCardiacMirrorSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaCardiacMirrorSpec_cxx; } +// Forward declaration of `HybridHaptikaHealthKitSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaHealthKitSpec_cxx; } +// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaPPGFrameProcessorSpec_cxx; } + +// Include C++ defined types +#include "FingerContactState.hpp" +#include "HealthMetricType.hpp" +#include "HybridHaptikaCardiacMirrorSpec.hpp" +#include "HybridHaptikaHealthKitSpec.hpp" +#include "HybridHaptikaPPGFrameProcessorSpec.hpp" +#include "NativeAcceptedBeat.hpp" +#include "NativeCardiacMirrorHeartbeat.hpp" +#include "NativeCardiacMirrorPatternInfo.hpp" +#include "NativeHealthKitContext.hpp" +#include "NativeHealthMetricSample.hpp" +#include "NativeHeartbeatSeriesSummary.hpp" +#include "NativePPGProcessingResult.hpp" +#include "PPGFrameUpdate.hpp" +#include "ThermalState.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * Contains specialized versions of C++ templated types so they can be accessed from Swift, + * as well as helper functions to interact with those C++ types from Swift. + */ +namespace margelo::nitro::haptika::ppg::bridge::swift { + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_double_ = std::optional; + inline std::optional create_std__optional_double_(const double& value) noexcept { + return std::optional(value); + } + inline bool has_value_std__optional_double_(const std::optional& optional) noexcept { + return optional.has_value(); + } + inline double get_std__optional_double_(const std::optional& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::vector + /** + * Specialized version of `std::vector`. + */ + using std__vector_NativeAcceptedBeat_ = std::vector; + inline std::vector create_std__vector_NativeAcceptedBeat_(size_t size) noexcept { + std::vector vector; + vector.reserve(size); + return vector; + } + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_PPGFrameUpdate_ = std::optional; + inline std::optional create_std__optional_PPGFrameUpdate_(const PPGFrameUpdate& value) noexcept { + return std::optional(value); + } + inline bool has_value_std__optional_PPGFrameUpdate_(const std::optional& optional) noexcept { + return optional.has_value(); + } + inline PPGFrameUpdate get_std__optional_PPGFrameUpdate_(const std::optional& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::shared_ptr + /** + * Specialized version of `std::shared_ptr`. + */ + using std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_ = std::shared_ptr; + std::shared_ptr create_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_ cppType); + + // pragma MARK: std::weak_ptr + using std__weak_ptr_margelo__nitro__camera__HybridFrameSpec_ = std::weak_ptr; + inline std__weak_ptr_margelo__nitro__camera__HybridFrameSpec_ weakify_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(const std::shared_ptr& strong) noexcept { return strong; } + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_std__string_ = std::optional; + inline std::optional create_std__optional_std__string_(const std::string& value) noexcept { + return std::optional(value); + } + inline bool has_value_std__optional_std__string_(const std::optional& optional) noexcept { + return optional.has_value(); + } + inline std::string get_std__optional_std__string_(const std::optional& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::vector + /** + * Specialized version of `std::vector`. + */ + using std__vector_std__string_ = std::vector; + inline std::vector create_std__vector_std__string_(size_t size) noexcept { + std::vector vector; + vector.reserve(size); + return vector; + } + + // pragma MARK: std::shared_ptr + /** + * Specialized version of `std::shared_ptr`. + */ + using std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ = std::shared_ptr; + std::shared_ptr create_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ cppType); + + // pragma MARK: std::weak_ptr + using std__weak_ptr_HybridHaptikaPPGFrameProcessorSpec_ = std::weak_ptr; + inline std__weak_ptr_HybridHaptikaPPGFrameProcessorSpec_ weakify_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(const std::shared_ptr& strong) noexcept { return strong; } + + // pragma MARK: Result + using Result_void_ = Result; + inline Result_void_ create_Result_void_() noexcept { + return Result::withValue(); + } + inline Result_void_ create_Result_void_(const std::exception_ptr& error) noexcept { + return Result::withError(error); + } + + // pragma MARK: Result> + using Result_std__optional_PPGFrameUpdate__ = Result>; + inline Result_std__optional_PPGFrameUpdate__ create_Result_std__optional_PPGFrameUpdate__(const std::optional& value) noexcept { + return Result>::withValue(value); + } + inline Result_std__optional_PPGFrameUpdate__ create_Result_std__optional_PPGFrameUpdate__(const std::exception_ptr& error) noexcept { + return Result>::withError(error); + } + + // pragma MARK: Result + using Result_NativePPGProcessingResult_ = Result; + inline Result_NativePPGProcessingResult_ create_Result_NativePPGProcessingResult_(const NativePPGProcessingResult& value) noexcept { + return Result::withValue(value); + } + inline Result_NativePPGProcessingResult_ create_Result_NativePPGProcessingResult_(const std::exception_ptr& error) noexcept { + return Result::withError(error); + } + + // pragma MARK: Result + using Result_ThermalState_ = Result; + inline Result_ThermalState_ create_Result_ThermalState_(ThermalState value) noexcept { + return Result::withValue(std::move(value)); + } + inline Result_ThermalState_ create_Result_ThermalState_(const std::exception_ptr& error) noexcept { + return Result::withError(error); + } + + // pragma MARK: std::shared_ptr> + /** + * Specialized version of `std::shared_ptr>`. + */ + using std__shared_ptr_Promise_bool__ = std::shared_ptr>; + inline std::shared_ptr> create_std__shared_ptr_Promise_bool__() noexcept { + return Promise::create(); + } + inline PromiseHolder wrap_std__shared_ptr_Promise_bool__(std::shared_ptr> promise) noexcept { + return PromiseHolder(std::move(promise)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_bool = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_bool_Wrapper final { + public: + explicit Func_void_bool_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(bool result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_bool create_Func_void_bool(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_bool_Wrapper wrap_Func_void_bool(Func_void_bool value) noexcept { + return Func_void_bool_Wrapper(std::move(value)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_std__exception_ptr = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_std__exception_ptr_Wrapper final { + public: + explicit Func_void_std__exception_ptr_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(std::exception_ptr error) const noexcept { + _function->operator()(error); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_std__exception_ptr create_Func_void_std__exception_ptr(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_std__exception_ptr_Wrapper wrap_Func_void_std__exception_ptr(Func_void_std__exception_ptr value) noexcept { + return Func_void_std__exception_ptr_Wrapper(std::move(value)); + } + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_NativeHealthMetricSample_ = std::optional; + inline std::optional create_std__optional_NativeHealthMetricSample_(const NativeHealthMetricSample& value) noexcept { + return std::optional(value); + } + inline bool has_value_std__optional_NativeHealthMetricSample_(const std::optional& optional) noexcept { + return optional.has_value(); + } + inline NativeHealthMetricSample get_std__optional_NativeHealthMetricSample_(const std::optional& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::vector + /** + * Specialized version of `std::vector`. + */ + using std__vector_NativeHealthMetricSample_ = std::vector; + inline std::vector create_std__vector_NativeHealthMetricSample_(size_t size) noexcept { + std::vector vector; + vector.reserve(size); + return vector; + } + + // pragma MARK: std::shared_ptr> + /** + * Specialized version of `std::shared_ptr>`. + */ + using std__shared_ptr_Promise_NativeHealthKitContext__ = std::shared_ptr>; + inline std::shared_ptr> create_std__shared_ptr_Promise_NativeHealthKitContext__() noexcept { + return Promise::create(); + } + inline PromiseHolder wrap_std__shared_ptr_Promise_NativeHealthKitContext__(std::shared_ptr> promise) noexcept { + return PromiseHolder(std::move(promise)); + } + + // pragma MARK: std::function + /** + * Specialized version of `std::function`. + */ + using Func_void_NativeHealthKitContext = std::function; + /** + * Wrapper class for a `std::function`, this can be used from Swift. + */ + class Func_void_NativeHealthKitContext_Wrapper final { + public: + explicit Func_void_NativeHealthKitContext_Wrapper(std::function&& func): _function(std::make_unique>(std::move(func))) {} + inline void call(NativeHealthKitContext result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr> _function; + } SWIFT_NONCOPYABLE; + Func_void_NativeHealthKitContext create_Func_void_NativeHealthKitContext(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_NativeHealthKitContext_Wrapper wrap_Func_void_NativeHealthKitContext(Func_void_NativeHealthKitContext value) noexcept { + return Func_void_NativeHealthKitContext_Wrapper(std::move(value)); + } + + // pragma MARK: std::vector + /** + * Specialized version of `std::vector`. + */ + using std__vector_double_ = std::vector; + inline std::vector create_std__vector_double_(size_t size) noexcept { + std::vector vector; + vector.reserve(size); + return vector; + } + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_NativeHeartbeatSeriesSummary_ = std::optional; + inline std::optional create_std__optional_NativeHeartbeatSeriesSummary_(const NativeHeartbeatSeriesSummary& value) noexcept { + return std::optional(value); + } + inline bool has_value_std__optional_NativeHeartbeatSeriesSummary_(const std::optional& optional) noexcept { + return optional.has_value(); + } + inline NativeHeartbeatSeriesSummary get_std__optional_NativeHeartbeatSeriesSummary_(const std::optional& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::shared_ptr>> + /** + * Specialized version of `std::shared_ptr>>`. + */ + using std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___ = std::shared_ptr>>; + inline std::shared_ptr>> create_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___() noexcept { + return Promise>::create(); + } + inline PromiseHolder> wrap_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___(std::shared_ptr>> promise) noexcept { + return PromiseHolder>(std::move(promise)); + } + + // pragma MARK: std::function& /* result */)> + /** + * Specialized version of `std::function&)>`. + */ + using Func_void_std__optional_NativeHeartbeatSeriesSummary_ = std::function& /* result */)>; + /** + * Wrapper class for a `std::function& / * result * /)>`, this can be used from Swift. + */ + class Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper final { + public: + explicit Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper(std::function& /* result */)>&& func): _function(std::make_unique& /* result */)>>(std::move(func))) {} + inline void call(std::optional result) const noexcept { + _function->operator()(result); + } + private: + std::unique_ptr& /* result */)>> _function; + } SWIFT_NONCOPYABLE; + Func_void_std__optional_NativeHeartbeatSeriesSummary_ create_Func_void_std__optional_NativeHeartbeatSeriesSummary_(void* NON_NULL swiftClosureWrapper) noexcept; + inline Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper wrap_Func_void_std__optional_NativeHeartbeatSeriesSummary_(Func_void_std__optional_NativeHeartbeatSeriesSummary_ value) noexcept { + return Func_void_std__optional_NativeHeartbeatSeriesSummary__Wrapper(std::move(value)); + } + + // pragma MARK: std::shared_ptr + /** + * Specialized version of `std::shared_ptr`. + */ + using std__shared_ptr_HybridHaptikaHealthKitSpec_ = std::shared_ptr; + std::shared_ptr create_std__shared_ptr_HybridHaptikaHealthKitSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_HybridHaptikaHealthKitSpec_(std__shared_ptr_HybridHaptikaHealthKitSpec_ cppType); + + // pragma MARK: std::weak_ptr + using std__weak_ptr_HybridHaptikaHealthKitSpec_ = std::weak_ptr; + inline std__weak_ptr_HybridHaptikaHealthKitSpec_ weakify_std__shared_ptr_HybridHaptikaHealthKitSpec_(const std::shared_ptr& strong) noexcept { return strong; } + + // pragma MARK: Result + using Result_bool_ = Result; + inline Result_bool_ create_Result_bool_(bool value) noexcept { + return Result::withValue(std::move(value)); + } + inline Result_bool_ create_Result_bool_(const std::exception_ptr& error) noexcept { + return Result::withError(error); + } + + // pragma MARK: Result>> + using Result_std__shared_ptr_Promise_bool___ = Result>>; + inline Result_std__shared_ptr_Promise_bool___ create_Result_std__shared_ptr_Promise_bool___(const std::shared_ptr>& value) noexcept { + return Result>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_bool___ create_Result_std__shared_ptr_Promise_bool___(const std::exception_ptr& error) noexcept { + return Result>>::withError(error); + } + + // pragma MARK: Result>> + using Result_std__shared_ptr_Promise_NativeHealthKitContext___ = Result>>; + inline Result_std__shared_ptr_Promise_NativeHealthKitContext___ create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(const std::shared_ptr>& value) noexcept { + return Result>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_NativeHealthKitContext___ create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(const std::exception_ptr& error) noexcept { + return Result>>::withError(error); + } + + // pragma MARK: Result>>> + using Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ = Result>>>; + inline Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(const std::shared_ptr>>& value) noexcept { + return Result>>>::withValue(value); + } + inline Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(const std::exception_ptr& error) noexcept { + return Result>>>::withError(error); + } + + // pragma MARK: std::optional + /** + * Specialized version of `std::optional`. + */ + using std__optional_NativeCardiacMirrorHeartbeat_ = std::optional; + inline std::optional create_std__optional_NativeCardiacMirrorHeartbeat_(const NativeCardiacMirrorHeartbeat& value) noexcept { + return std::optional(value); + } + inline bool has_value_std__optional_NativeCardiacMirrorHeartbeat_(const std::optional& optional) noexcept { + return optional.has_value(); + } + inline NativeCardiacMirrorHeartbeat get_std__optional_NativeCardiacMirrorHeartbeat_(const std::optional& optional) noexcept { + return optional.value(); + } + + // pragma MARK: std::shared_ptr + /** + * Specialized version of `std::shared_ptr`. + */ + using std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ = std::shared_ptr; + std::shared_ptr create_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(void* NON_NULL swiftUnsafePointer) noexcept; + void* NON_NULL get_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ cppType); + + // pragma MARK: std::weak_ptr + using std__weak_ptr_HybridHaptikaCardiacMirrorSpec_ = std::weak_ptr; + inline std__weak_ptr_HybridHaptikaCardiacMirrorSpec_ weakify_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(const std::shared_ptr& strong) noexcept { return strong; } + + // pragma MARK: Result + using Result_NativeCardiacMirrorPatternInfo_ = Result; + inline Result_NativeCardiacMirrorPatternInfo_ create_Result_NativeCardiacMirrorPatternInfo_(const NativeCardiacMirrorPatternInfo& value) noexcept { + return Result::withValue(value); + } + inline Result_NativeCardiacMirrorPatternInfo_ create_Result_NativeCardiacMirrorPatternInfo_(const std::exception_ptr& error) noexcept { + return Result::withError(error); + } + + // pragma MARK: Result> + using Result_std__optional_NativeCardiacMirrorHeartbeat__ = Result>; + inline Result_std__optional_NativeCardiacMirrorHeartbeat__ create_Result_std__optional_NativeCardiacMirrorHeartbeat__(const std::optional& value) noexcept { + return Result>::withValue(value); + } + inline Result_std__optional_NativeCardiacMirrorHeartbeat__ create_Result_std__optional_NativeCardiacMirrorHeartbeat__(const std::exception_ptr& error) noexcept { + return Result>::withError(error); + } + + // pragma MARK: Result + using Result_double_ = Result; + inline Result_double_ create_Result_double_(double value) noexcept { + return Result::withValue(std::move(value)); + } + inline Result_double_ create_Result_double_(const std::exception_ptr& error) noexcept { + return Result::withError(error); + } + +} // namespace margelo::nitro::haptika::ppg::bridge::swift diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Umbrella.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Umbrella.hpp new file mode 100644 index 0000000..680b216 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPG-Swift-Cxx-Umbrella.hpp @@ -0,0 +1,110 @@ +/// +/// HaptikaPPG-Swift-Cxx-Umbrella.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +// Forward declarations of C++ defined types +// Forward declaration of `FingerContactState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class FingerContactState; } +// Forward declaration of `HealthMetricType` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class HealthMetricType; } +// Forward declaration of `HybridFrameSpec` to properly resolve imports. +namespace margelo::nitro::camera { class HybridFrameSpec; } +// Forward declaration of `HybridHaptikaCardiacMirrorSpec` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { class HybridHaptikaCardiacMirrorSpec; } +// Forward declaration of `HybridHaptikaHealthKitSpec` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { class HybridHaptikaHealthKitSpec; } +// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { class HybridHaptikaPPGFrameProcessorSpec; } +// Forward declaration of `NativeAcceptedBeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeAcceptedBeat; } +// Forward declaration of `NativeBPMEstimate` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeBPMEstimate; } +// Forward declaration of `NativeCardiacMirrorHeartbeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorHeartbeat; } +// Forward declaration of `NativeCardiacMirrorPatternInfo` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorPatternInfo; } +// Forward declaration of `NativeHealthKitContext` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthKitContext; } +// Forward declaration of `NativeHealthMetricSample` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthMetricSample; } +// Forward declaration of `NativeHeartbeatSeriesSummary` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHeartbeatSeriesSummary; } +// Forward declaration of `NativePPGConfiguration` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePPGConfiguration; } +// Forward declaration of `NativePPGProcessingResult` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePPGProcessingResult; } +// Forward declaration of `NativePRVMetrics` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePRVMetrics; } +// Forward declaration of `NativeSignalQualityResult` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeSignalQualityResult; } +// Forward declaration of `PPGFrameUpdate` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct PPGFrameUpdate; } +// Forward declaration of `PPGSessionPurpose` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class PPGSessionPurpose; } +// Forward declaration of `ThermalState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class ThermalState; } + +// Include C++ defined types +#include "FingerContactState.hpp" +#include "HealthMetricType.hpp" +#include "HybridHaptikaCardiacMirrorSpec.hpp" +#include "HybridHaptikaHealthKitSpec.hpp" +#include "HybridHaptikaPPGFrameProcessorSpec.hpp" +#include "NativeAcceptedBeat.hpp" +#include "NativeBPMEstimate.hpp" +#include "NativeCardiacMirrorHeartbeat.hpp" +#include "NativeCardiacMirrorPatternInfo.hpp" +#include "NativeHealthKitContext.hpp" +#include "NativeHealthMetricSample.hpp" +#include "NativeHeartbeatSeriesSummary.hpp" +#include "NativePPGConfiguration.hpp" +#include "NativePPGProcessingResult.hpp" +#include "NativePRVMetrics.hpp" +#include "NativeSignalQualityResult.hpp" +#include "PPGFrameUpdate.hpp" +#include "PPGSessionPurpose.hpp" +#include "ThermalState.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +// C++ helpers for Swift +#include "HaptikaPPG-Swift-Cxx-Bridge.hpp" + +// Common C++ types used in Swift +#include +#include +#include +#include + +// Forward declarations of Swift defined types +// Forward declaration of `HybridFrameSpec_cxx` to properly resolve imports. +namespace VisionCamera { class HybridFrameSpec_cxx; } +// Forward declaration of `HybridHaptikaCardiacMirrorSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaCardiacMirrorSpec_cxx; } +// Forward declaration of `HybridHaptikaHealthKitSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaHealthKitSpec_cxx; } +// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaPPGFrameProcessorSpec_cxx; } + +// Include Swift defined types +#if __has_include("HaptikaPPG-Swift.h") +// This header is generated by Xcode/Swift on every app build. +// If it cannot be found, make sure the Swift module's name (= podspec name) is actually "HaptikaPPG". +#include "HaptikaPPG-Swift.h" +// Same as above, but used when building with frameworks (`use_frameworks`) +#elif __has_include() +#include +#else +#error HaptikaPPG's autogenerated Swift header cannot be found! Make sure the Swift module's name (= podspec name) is actually "HaptikaPPG", and try building the app first. +#endif diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPGAutolinking.mm b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPGAutolinking.mm new file mode 100644 index 0000000..7d6ca6d --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPGAutolinking.mm @@ -0,0 +1,49 @@ +/// +/// HaptikaPPGAutolinking.mm +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#import +#import +#import "HaptikaPPG-Swift-Cxx-Umbrella.hpp" +#import + +#include "HybridHaptikaPPGFrameProcessorSpecSwift.hpp" +#include "HybridHaptikaHealthKitSpecSwift.hpp" +#include "HybridHaptikaCardiacMirrorSpecSwift.hpp" + +@interface HaptikaPPGAutolinking : NSObject +@end + +@implementation HaptikaPPGAutolinking + ++ (void) load { + using namespace margelo::nitro; + using namespace margelo::nitro::haptika::ppg; + + HybridObjectRegistry::registerHybridObjectConstructor( + "HaptikaPPGFrameProcessor", + []() -> std::shared_ptr { + std::shared_ptr hybridObject = HaptikaPPG::HaptikaPPGAutolinking::createHaptikaPPGFrameProcessor(); + return hybridObject; + } + ); + HybridObjectRegistry::registerHybridObjectConstructor( + "HaptikaHealthKit", + []() -> std::shared_ptr { + std::shared_ptr hybridObject = HaptikaPPG::HaptikaPPGAutolinking::createHaptikaHealthKit(); + return hybridObject; + } + ); + HybridObjectRegistry::registerHybridObjectConstructor( + "HaptikaCardiacMirror", + []() -> std::shared_ptr { + std::shared_ptr hybridObject = HaptikaPPG::HaptikaPPGAutolinking::createHaptikaCardiacMirror(); + return hybridObject; + } + ); +} + +@end diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPGAutolinking.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPGAutolinking.swift new file mode 100644 index 0000000..5097cea --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/HaptikaPPGAutolinking.swift @@ -0,0 +1,50 @@ +/// +/// HaptikaPPGAutolinking.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +// TODO: Use empty enums once Swift supports exporting them as namespaces +// See: https://github.com/swiftlang/swift/pull/83616 +public final class HaptikaPPGAutolinking { + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + public static func createHaptikaPPGFrameProcessor() -> bridge.std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ { + let hybridObject = HybridHaptikaPPGFrameProcessor() + return { () -> bridge.std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ in + let __cxxWrapped = hybridObject.getCxxWrapper() + return __cxxWrapped.getCxxPart() + }() + } + + public static func isHaptikaPPGFrameProcessorRecyclable() -> Bool { + return HybridHaptikaPPGFrameProcessor.self is any RecyclableView.Type + } + + public static func createHaptikaHealthKit() -> bridge.std__shared_ptr_HybridHaptikaHealthKitSpec_ { + let hybridObject = HybridHaptikaHealthKit() + return { () -> bridge.std__shared_ptr_HybridHaptikaHealthKitSpec_ in + let __cxxWrapped = hybridObject.getCxxWrapper() + return __cxxWrapped.getCxxPart() + }() + } + + public static func isHaptikaHealthKitRecyclable() -> Bool { + return HybridHaptikaHealthKit.self is any RecyclableView.Type + } + + public static func createHaptikaCardiacMirror() -> bridge.std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ { + let hybridObject = HybridHaptikaCardiacMirror() + return { () -> bridge.std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ in + let __cxxWrapped = hybridObject.getCxxWrapper() + return __cxxWrapped.getCxxPart() + }() + } + + public static func isHaptikaCardiacMirrorRecyclable() -> Bool { + return HybridHaptikaCardiacMirror.self is any RecyclableView.Type + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaCardiacMirrorSpecSwift.cpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaCardiacMirrorSpecSwift.cpp new file mode 100644 index 0000000..4916eb6 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaCardiacMirrorSpecSwift.cpp @@ -0,0 +1,11 @@ +/// +/// HybridHaptikaCardiacMirrorSpecSwift.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridHaptikaCardiacMirrorSpecSwift.hpp" + +namespace margelo::nitro::haptika::ppg { +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaCardiacMirrorSpecSwift.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaCardiacMirrorSpecSwift.hpp new file mode 100644 index 0000000..042e898 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaCardiacMirrorSpecSwift.hpp @@ -0,0 +1,150 @@ +/// +/// HybridHaptikaCardiacMirrorSpecSwift.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include "HybridHaptikaCardiacMirrorSpec.hpp" + +// Forward declaration of `HybridHaptikaCardiacMirrorSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaCardiacMirrorSpec_cxx; } + +// Forward declaration of `NativeCardiacMirrorPatternInfo` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorPatternInfo; } +// Forward declaration of `NativeCardiacMirrorHeartbeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorHeartbeat; } + +#include "NativeCardiacMirrorPatternInfo.hpp" +#include +#include "NativeCardiacMirrorHeartbeat.hpp" +#include + +#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp" + +namespace margelo::nitro::haptika::ppg { + + /** + * The C++ part of HybridHaptikaCardiacMirrorSpec_cxx.swift. + * + * HybridHaptikaCardiacMirrorSpecSwift (C++) accesses HybridHaptikaCardiacMirrorSpec_cxx (Swift), and might + * contain some additional bridging code for C++ <> Swift interop. + * + * Since this obviously introduces an overhead, I hope at some point in + * the future, HybridHaptikaCardiacMirrorSpec_cxx can directly inherit from the C++ class HybridHaptikaCardiacMirrorSpec + * to simplify the whole structure and memory management. + */ + class HybridHaptikaCardiacMirrorSpecSwift: public virtual HybridHaptikaCardiacMirrorSpec { + public: + // Constructor from a Swift instance + explicit HybridHaptikaCardiacMirrorSpecSwift(const HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx& swiftPart): + HybridObject(HybridHaptikaCardiacMirrorSpec::TAG), + _swiftPart(swiftPart) { } + + public: + // Get the Swift part + inline HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx& getSwiftPart() noexcept { + return _swiftPart; + } + + public: + inline size_t getExternalMemorySize() noexcept override { + return _swiftPart.getMemorySize(); + } + bool equals(const std::shared_ptr& other) override { + if (auto otherCast = std::dynamic_pointer_cast(other)) { + return _swiftPart.equals(otherCast->_swiftPart); + } + return false; + } + void dispose() noexcept override { + _swiftPart.dispose(); + } + std::string toString() override { + return _swiftPart.toString(); + } + + public: + // Properties + + + public: + // Methods + inline bool isSupported() override { + auto __result = _swiftPart.isSupported(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline NativeCardiacMirrorPatternInfo patternInfo() override { + auto __result = _swiftPart.patternInfo(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline void start(double targetBpm) override { + auto __result = _swiftPart.start(std::forward(targetBpm)); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline void pulse() override { + auto __result = _swiftPart.pulse(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline void stop() override { + auto __result = _swiftPart.stop(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline void startWatchSession() override { + auto __result = _swiftPart.startWatchSession(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline bool watchReachable() override { + auto __result = _swiftPart.watchReachable(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::optional latestHeartbeat() override { + auto __result = _swiftPart.latestHeartbeat(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline double playRegulationProtocol(const std::string& kind) override { + auto __result = _swiftPart.playRegulationProtocol(kind); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline void stopRegulationProtocol() override { + auto __result = _swiftPart.stopRegulationProtocol(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + + private: + HaptikaPPG::HybridHaptikaCardiacMirrorSpec_cxx _swiftPart; + }; + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaHealthKitSpecSwift.cpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaHealthKitSpecSwift.cpp new file mode 100644 index 0000000..b6a3bfe --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaHealthKitSpecSwift.cpp @@ -0,0 +1,11 @@ +/// +/// HybridHaptikaHealthKitSpecSwift.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridHaptikaHealthKitSpecSwift.hpp" + +namespace margelo::nitro::haptika::ppg { +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaHealthKitSpecSwift.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaHealthKitSpecSwift.hpp new file mode 100644 index 0000000..906c20f --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaHealthKitSpecSwift.hpp @@ -0,0 +1,120 @@ +/// +/// HybridHaptikaHealthKitSpecSwift.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include "HybridHaptikaHealthKitSpec.hpp" + +// Forward declaration of `HybridHaptikaHealthKitSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaHealthKitSpec_cxx; } + +// Forward declaration of `NativeHealthKitContext` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthKitContext; } +// Forward declaration of `NativeHealthMetricSample` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthMetricSample; } +// Forward declaration of `HealthMetricType` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class HealthMetricType; } +// Forward declaration of `NativeHeartbeatSeriesSummary` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHeartbeatSeriesSummary; } + +#include +#include "NativeHealthKitContext.hpp" +#include "NativeHealthMetricSample.hpp" +#include +#include "HealthMetricType.hpp" +#include +#include +#include "NativeHeartbeatSeriesSummary.hpp" + +#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp" + +namespace margelo::nitro::haptika::ppg { + + /** + * The C++ part of HybridHaptikaHealthKitSpec_cxx.swift. + * + * HybridHaptikaHealthKitSpecSwift (C++) accesses HybridHaptikaHealthKitSpec_cxx (Swift), and might + * contain some additional bridging code for C++ <> Swift interop. + * + * Since this obviously introduces an overhead, I hope at some point in + * the future, HybridHaptikaHealthKitSpec_cxx can directly inherit from the C++ class HybridHaptikaHealthKitSpec + * to simplify the whole structure and memory management. + */ + class HybridHaptikaHealthKitSpecSwift: public virtual HybridHaptikaHealthKitSpec { + public: + // Constructor from a Swift instance + explicit HybridHaptikaHealthKitSpecSwift(const HaptikaPPG::HybridHaptikaHealthKitSpec_cxx& swiftPart): + HybridObject(HybridHaptikaHealthKitSpec::TAG), + _swiftPart(swiftPart) { } + + public: + // Get the Swift part + inline HaptikaPPG::HybridHaptikaHealthKitSpec_cxx& getSwiftPart() noexcept { + return _swiftPart; + } + + public: + inline size_t getExternalMemorySize() noexcept override { + return _swiftPart.getMemorySize(); + } + bool equals(const std::shared_ptr& other) override { + if (auto otherCast = std::dynamic_pointer_cast(other)) { + return _swiftPart.equals(otherCast->_swiftPart); + } + return false; + } + void dispose() noexcept override { + _swiftPart.dispose(); + } + std::string toString() override { + return _swiftPart.toString(); + } + + public: + // Properties + + + public: + // Methods + inline bool isAvailable() override { + auto __result = _swiftPart.isAvailable(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> requestReadAuthorization() override { + auto __result = _swiftPart.requestReadAuthorization(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr> fetchContext(double lookbackDays) override { + auto __result = _swiftPart.fetchContext(std::forward(lookbackDays)); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline std::shared_ptr>> fetchLatestHeartbeatSeries() override { + auto __result = _swiftPart.fetchLatestHeartbeatSeries(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + + private: + HaptikaPPG::HybridHaptikaHealthKitSpec_cxx _swiftPart; + }; + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaPPGFrameProcessorSpecSwift.cpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaPPGFrameProcessorSpecSwift.cpp new file mode 100644 index 0000000..8c57fb2 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaPPGFrameProcessorSpecSwift.cpp @@ -0,0 +1,11 @@ +/// +/// HybridHaptikaPPGFrameProcessorSpecSwift.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridHaptikaPPGFrameProcessorSpecSwift.hpp" + +namespace margelo::nitro::haptika::ppg { +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaPPGFrameProcessorSpecSwift.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaPPGFrameProcessorSpecSwift.hpp new file mode 100644 index 0000000..2ef3567 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/c++/HybridHaptikaPPGFrameProcessorSpecSwift.hpp @@ -0,0 +1,145 @@ +/// +/// HybridHaptikaPPGFrameProcessorSpecSwift.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#include "HybridHaptikaPPGFrameProcessorSpec.hpp" + +// Forward declaration of `HybridHaptikaPPGFrameProcessorSpec_cxx` to properly resolve imports. +namespace HaptikaPPG { class HybridHaptikaPPGFrameProcessorSpec_cxx; } + +// Forward declaration of `NativePPGConfiguration` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePPGConfiguration; } +// Forward declaration of `PPGSessionPurpose` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class PPGSessionPurpose; } +// Forward declaration of `PPGFrameUpdate` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct PPGFrameUpdate; } +// Forward declaration of `FingerContactState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class FingerContactState; } +// Forward declaration of `ThermalState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class ThermalState; } +// Forward declaration of `NativeAcceptedBeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeAcceptedBeat; } +// Forward declaration of `HybridFrameSpec` to properly resolve imports. +namespace margelo::nitro::camera { class HybridFrameSpec; } +// Forward declaration of `NativePPGProcessingResult` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePPGProcessingResult; } +// Forward declaration of `NativeBPMEstimate` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeBPMEstimate; } +// Forward declaration of `NativePRVMetrics` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePRVMetrics; } +// Forward declaration of `NativeSignalQualityResult` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeSignalQualityResult; } + +#include "NativePPGConfiguration.hpp" +#include "PPGSessionPurpose.hpp" +#include "PPGFrameUpdate.hpp" +#include +#include "FingerContactState.hpp" +#include "ThermalState.hpp" +#include "NativeAcceptedBeat.hpp" +#include +#include +#include +#include "NativePPGProcessingResult.hpp" +#include +#include "NativeBPMEstimate.hpp" +#include "NativePRVMetrics.hpp" +#include "NativeSignalQualityResult.hpp" + +#include "HaptikaPPG-Swift-Cxx-Umbrella.hpp" + +namespace margelo::nitro::haptika::ppg { + + /** + * The C++ part of HybridHaptikaPPGFrameProcessorSpec_cxx.swift. + * + * HybridHaptikaPPGFrameProcessorSpecSwift (C++) accesses HybridHaptikaPPGFrameProcessorSpec_cxx (Swift), and might + * contain some additional bridging code for C++ <> Swift interop. + * + * Since this obviously introduces an overhead, I hope at some point in + * the future, HybridHaptikaPPGFrameProcessorSpec_cxx can directly inherit from the C++ class HybridHaptikaPPGFrameProcessorSpec + * to simplify the whole structure and memory management. + */ + class HybridHaptikaPPGFrameProcessorSpecSwift: public virtual HybridHaptikaPPGFrameProcessorSpec { + public: + // Constructor from a Swift instance + explicit HybridHaptikaPPGFrameProcessorSpecSwift(const HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx& swiftPart): + HybridObject(HybridHaptikaPPGFrameProcessorSpec::TAG), + _swiftPart(swiftPart) { } + + public: + // Get the Swift part + inline HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx& getSwiftPart() noexcept { + return _swiftPart; + } + + public: + inline size_t getExternalMemorySize() noexcept override { + return _swiftPart.getMemorySize(); + } + bool equals(const std::shared_ptr& other) override { + if (auto otherCast = std::dynamic_pointer_cast(other)) { + return _swiftPart.equals(otherCast->_swiftPart); + } + return false; + } + void dispose() noexcept override { + _swiftPart.dispose(); + } + std::string toString() override { + return _swiftPart.toString(); + } + + public: + // Properties + + + public: + // Methods + inline void startMeasurement(const NativePPGConfiguration& configuration) override { + auto __result = _swiftPart.startMeasurement(std::forward(configuration)); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline std::optional processFrame(const std::shared_ptr& frame) override { + auto __result = _swiftPart.processFrame(frame); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline NativePPGProcessingResult finishMeasurement() override { + auto __result = _swiftPart.finishMeasurement(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + inline void cancelMeasurement() override { + auto __result = _swiftPart.cancelMeasurement(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + } + inline ThermalState getThermalState() override { + auto __result = _swiftPart.getThermalState(); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } + + private: + HaptikaPPG::HybridHaptikaPPGFrameProcessorSpec_cxx _swiftPart; + }; + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/FingerContactState.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/FingerContactState.swift new file mode 100644 index 0000000..8c9d2b9 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/FingerContactState.swift @@ -0,0 +1,56 @@ +/// +/// FingerContactState.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +/** + * Represents the JS union `FingerContactState`, backed by a C++ enum. + */ +public typealias FingerContactState = margelo.nitro.haptika.ppg.FingerContactState + +public extension FingerContactState { + /** + * Get a FingerContactState for the given String value, or + * return `nil` if the given value was invalid/unknown. + */ + init?(fromString string: String) { + switch string { + case "absent": + self = .absent + case "adjusting": + self = .adjusting + case "valid": + self = .valid + case "tooMuchPressure": + self = .toomuchpressure + case "overexposed": + self = .overexposed + case "unstable": + self = .unstable + default: + return nil + } + } + + /** + * Get the String value this FingerContactState represents. + */ + var stringValue: String { + switch self { + case .absent: + return "absent" + case .adjusting: + return "adjusting" + case .valid: + return "valid" + case .toomuchpressure: + return "tooMuchPressure" + case .overexposed: + return "overexposed" + case .unstable: + return "unstable" + } + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_NativeHealthKitContext.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_NativeHealthKitContext.swift new file mode 100644 index 0000000..b344976 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_NativeHealthKitContext.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_NativeHealthKitContext.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ value: NativeHealthKitContext) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_NativeHealthKitContext { + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + private let closure: (_ value: NativeHealthKitContext) -> Void + + public init(_ closure: @escaping (_ value: NativeHealthKitContext) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: NativeHealthKitContext) -> Void { + self.closure(value) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_NativeHealthKitContext`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_NativeHealthKitContext { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_bool.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_bool.swift new file mode 100644 index 0000000..9803a24 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_bool.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_bool.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ value: Bool) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_bool { + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + private let closure: (_ value: Bool) -> Void + + public init(_ closure: @escaping (_ value: Bool) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: Bool) -> Void { + self.closure(value) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_bool`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_bool { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift new file mode 100644 index 0000000..78f7980 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_std__exception_ptr.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ error: Error) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_std__exception_ptr { + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + private let closure: (_ error: Error) -> Void + + public init(_ closure: @escaping (_ error: Error) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(error: std.exception_ptr) -> Void { + self.closure(RuntimeError.from(cppError: error)) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_std__exception_ptr`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__exception_ptr { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_std__optional_NativeHeartbeatSeriesSummary_.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_std__optional_NativeHeartbeatSeriesSummary_.swift new file mode 100644 index 0000000..41891dc --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/Func_void_std__optional_NativeHeartbeatSeriesSummary_.swift @@ -0,0 +1,46 @@ +/// +/// Func_void_std__optional_NativeHeartbeatSeriesSummary_.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Wraps a Swift `(_ value: NativeHeartbeatSeriesSummary?) -> Void` as a class. + * This class can be used from C++, e.g. to wrap the Swift closure as a `std::function`. + */ +public final class Func_void_std__optional_NativeHeartbeatSeriesSummary_ { + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + private let closure: (_ value: NativeHeartbeatSeriesSummary?) -> Void + + public init(_ closure: @escaping (_ value: NativeHeartbeatSeriesSummary?) -> Void) { + self.closure = closure + } + + @inline(__always) + public func call(value: bridge.std__optional_NativeHeartbeatSeriesSummary_) -> Void { + self.closure(value.value) + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + @inline(__always) + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `Func_void_std__optional_NativeHeartbeatSeriesSummary_`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + @inline(__always) + public static func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> Func_void_std__optional_NativeHeartbeatSeriesSummary_ { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HealthMetricType.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HealthMetricType.swift new file mode 100644 index 0000000..114e24e --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HealthMetricType.swift @@ -0,0 +1,44 @@ +/// +/// HealthMetricType.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +/** + * Represents the JS union `HealthMetricType`, backed by a C++ enum. + */ +public typealias HealthMetricType = margelo.nitro.haptika.ppg.HealthMetricType + +public extension HealthMetricType { + /** + * Get a HealthMetricType for the given String value, or + * return `nil` if the given value was invalid/unknown. + */ + init?(fromString string: String) { + switch string { + case "heartRate": + self = .heartrate + case "restingHeartRate": + self = .restingheartrate + case "hrvSdnn": + self = .hrvsdnn + default: + return nil + } + } + + /** + * Get the String value this HealthMetricType represents. + */ + var stringValue: String { + switch self { + case .heartrate: + return "heartRate" + case .restingheartrate: + return "restingHeartRate" + case .hrvsdnn: + return "hrvSdnn" + } + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaCardiacMirrorSpec.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaCardiacMirrorSpec.swift new file mode 100644 index 0000000..66d3d98 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaCardiacMirrorSpec.swift @@ -0,0 +1,64 @@ +/// +/// HybridHaptikaCardiacMirrorSpec.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/// See ``HybridHaptikaCardiacMirrorSpec`` +public protocol HybridHaptikaCardiacMirrorSpec_protocol: HybridObject { + // Properties + + + // Methods + func isSupported() throws -> Bool + func patternInfo() throws -> NativeCardiacMirrorPatternInfo + func start(targetBpm: Double) throws -> Void + func pulse() throws -> Void + func stop() throws -> Void + func startWatchSession() throws -> Void + func watchReachable() throws -> Bool + func latestHeartbeat() throws -> NativeCardiacMirrorHeartbeat? + func playRegulationProtocol(kind: String) throws -> Double + func stopRegulationProtocol() throws -> Void +} + +public extension HybridHaptikaCardiacMirrorSpec_protocol { + /// Default implementation of ``HybridObject.toString`` + func toString() -> String { + return "[HybridObject HaptikaCardiacMirror]" + } +} + +/// See ``HybridHaptikaCardiacMirrorSpec`` +open class HybridHaptikaCardiacMirrorSpec_base { + private weak var cxxWrapper: HybridHaptikaCardiacMirrorSpec_cxx? = nil + public init() { } + public func getCxxWrapper() -> HybridHaptikaCardiacMirrorSpec_cxx { + #if DEBUG + guard self is any HybridHaptikaCardiacMirrorSpec else { + fatalError("`self` is not a `HybridHaptikaCardiacMirrorSpec`! Did you accidentally inherit from `HybridHaptikaCardiacMirrorSpec_base` instead of `HybridHaptikaCardiacMirrorSpec`?") + } + #endif + if let cxxWrapper = self.cxxWrapper { + return cxxWrapper + } else { + let cxxWrapper = HybridHaptikaCardiacMirrorSpec_cxx(self as! any HybridHaptikaCardiacMirrorSpec) + self.cxxWrapper = cxxWrapper + return cxxWrapper + } + } +} + +/** + * A Swift base-protocol representing the HaptikaCardiacMirror HybridObject. + * Implement this protocol to create Swift-based instances of HaptikaCardiacMirror. + * ```swift + * class HybridHaptikaCardiacMirror : HybridHaptikaCardiacMirrorSpec { + * // ... + * } + * ``` + */ +public typealias HybridHaptikaCardiacMirrorSpec = HybridHaptikaCardiacMirrorSpec_protocol & HybridHaptikaCardiacMirrorSpec_base diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaCardiacMirrorSpec_cxx.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaCardiacMirrorSpec_cxx.swift new file mode 100644 index 0000000..88de12a --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaCardiacMirrorSpec_cxx.swift @@ -0,0 +1,247 @@ +/// +/// HybridHaptikaCardiacMirrorSpec_cxx.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * A class implementation that bridges HybridHaptikaCardiacMirrorSpec over to C++. + * In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined. + * + * Also, some Swift types need to be bridged with special handling: + * - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330) + * - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper + * - Throwing methods need to be wrapped with a Result type, as exceptions cannot be propagated to C++ + */ +open class HybridHaptikaCardiacMirrorSpec_cxx { + /** + * The Swift <> C++ bridge's namespace (`margelo::nitro::haptika::ppg::bridge::swift`) + * from `HaptikaPPG-Swift-Cxx-Bridge.hpp`. + * This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift. + */ + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Holds an instance of the `HybridHaptikaCardiacMirrorSpec` Swift protocol. + */ + private var __implementation: any HybridHaptikaCardiacMirrorSpec + + /** + * Holds a weak pointer to the C++ class that wraps the Swift class. + */ + private var __cxxPart: bridge.std__weak_ptr_HybridHaptikaCardiacMirrorSpec_ + + /** + * Create a new `HybridHaptikaCardiacMirrorSpec_cxx` that wraps the given `HybridHaptikaCardiacMirrorSpec`. + * All properties and methods bridge to C++ types. + */ + public init(_ implementation: any HybridHaptikaCardiacMirrorSpec) { + self.__implementation = implementation + self.__cxxPart = .init() + /* no base class */ + } + + /** + * Get the actual `HybridHaptikaCardiacMirrorSpec` instance this class wraps. + */ + @inline(__always) + public func getHybridHaptikaCardiacMirrorSpec() -> any HybridHaptikaCardiacMirrorSpec { + return __implementation + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `HybridHaptikaCardiacMirrorSpec_cxx`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridHaptikaCardiacMirrorSpec_cxx { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } + + /** + * Gets (or creates) the C++ part of this Hybrid Object. + * The C++ part is a `std::shared_ptr`. + */ + public func getCxxPart() -> bridge.std__shared_ptr_HybridHaptikaCardiacMirrorSpec_ { + let cachedCxxPart = self.__cxxPart.lock() + if Bool(fromCxx: cachedCxxPart) { + return cachedCxxPart + } else { + let newCxxPart = bridge.create_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(self.toUnsafe()) + __cxxPart = bridge.weakify_std__shared_ptr_HybridHaptikaCardiacMirrorSpec_(newCxxPart) + return newCxxPart + } + } + + + + /** + * Get the memory size of the Swift class (plus size of any other allocations) + * so the JS VM can properly track it and garbage-collect the JS object if needed. + */ + @inline(__always) + public var memorySize: Int { + return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize + } + + /** + * Compares this object with the given [other] object for reference equality. + */ + @inline(__always) + public func equals(other: HybridHaptikaCardiacMirrorSpec_cxx) -> Bool { + return self.__implementation === other.__implementation + } + + /** + * Call dispose() on the Swift class. + * This _may_ be called manually from JS. + */ + @inline(__always) + public func dispose() { + self.__implementation.dispose() + } + + /** + * Call toString() on the Swift class. + */ + @inline(__always) + public func toString() -> String { + return self.__implementation.toString() + } + + // Properties + + + // Methods + @inline(__always) + public final func isSupported() -> bridge.Result_bool_ { + do { + let __result = try self.__implementation.isSupported() + let __resultCpp = __result + return bridge.create_Result_bool_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_bool_(__exceptionPtr) + } + } + + @inline(__always) + public final func patternInfo() -> bridge.Result_NativeCardiacMirrorPatternInfo_ { + do { + let __result = try self.__implementation.patternInfo() + let __resultCpp = __result + return bridge.create_Result_NativeCardiacMirrorPatternInfo_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_NativeCardiacMirrorPatternInfo_(__exceptionPtr) + } + } + + @inline(__always) + public final func start(targetBpm: Double) -> bridge.Result_void_ { + do { + try self.__implementation.start(targetBpm: targetBpm) + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func pulse() -> bridge.Result_void_ { + do { + try self.__implementation.pulse() + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func stop() -> bridge.Result_void_ { + do { + try self.__implementation.stop() + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func startWatchSession() -> bridge.Result_void_ { + do { + try self.__implementation.startWatchSession() + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func watchReachable() -> bridge.Result_bool_ { + do { + let __result = try self.__implementation.watchReachable() + let __resultCpp = __result + return bridge.create_Result_bool_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_bool_(__exceptionPtr) + } + } + + @inline(__always) + public final func latestHeartbeat() -> bridge.Result_std__optional_NativeCardiacMirrorHeartbeat__ { + do { + let __result = try self.__implementation.latestHeartbeat() + let __resultCpp = { () -> bridge.std__optional_NativeCardiacMirrorHeartbeat_ in + if let __unwrappedValue = __result { + return bridge.create_std__optional_NativeCardiacMirrorHeartbeat_(__unwrappedValue) + } else { + return .init() + } + }() + return bridge.create_Result_std__optional_NativeCardiacMirrorHeartbeat__(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__optional_NativeCardiacMirrorHeartbeat__(__exceptionPtr) + } + } + + @inline(__always) + public final func playRegulationProtocol(kind: std.string) -> bridge.Result_double_ { + do { + let __result = try self.__implementation.playRegulationProtocol(kind: String(kind)) + let __resultCpp = __result + return bridge.create_Result_double_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_double_(__exceptionPtr) + } + } + + @inline(__always) + public final func stopRegulationProtocol() -> bridge.Result_void_ { + do { + try self.__implementation.stopRegulationProtocol() + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaHealthKitSpec.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaHealthKitSpec.swift new file mode 100644 index 0000000..cbacc48 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaHealthKitSpec.swift @@ -0,0 +1,58 @@ +/// +/// HybridHaptikaHealthKitSpec.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/// See ``HybridHaptikaHealthKitSpec`` +public protocol HybridHaptikaHealthKitSpec_protocol: HybridObject { + // Properties + + + // Methods + func isAvailable() throws -> Bool + func requestReadAuthorization() throws -> Promise + func fetchContext(lookbackDays: Double) throws -> Promise + func fetchLatestHeartbeatSeries() throws -> Promise +} + +public extension HybridHaptikaHealthKitSpec_protocol { + /// Default implementation of ``HybridObject.toString`` + func toString() -> String { + return "[HybridObject HaptikaHealthKit]" + } +} + +/// See ``HybridHaptikaHealthKitSpec`` +open class HybridHaptikaHealthKitSpec_base { + private weak var cxxWrapper: HybridHaptikaHealthKitSpec_cxx? = nil + public init() { } + public func getCxxWrapper() -> HybridHaptikaHealthKitSpec_cxx { + #if DEBUG + guard self is any HybridHaptikaHealthKitSpec else { + fatalError("`self` is not a `HybridHaptikaHealthKitSpec`! Did you accidentally inherit from `HybridHaptikaHealthKitSpec_base` instead of `HybridHaptikaHealthKitSpec`?") + } + #endif + if let cxxWrapper = self.cxxWrapper { + return cxxWrapper + } else { + let cxxWrapper = HybridHaptikaHealthKitSpec_cxx(self as! any HybridHaptikaHealthKitSpec) + self.cxxWrapper = cxxWrapper + return cxxWrapper + } + } +} + +/** + * A Swift base-protocol representing the HaptikaHealthKit HybridObject. + * Implement this protocol to create Swift-based instances of HaptikaHealthKit. + * ```swift + * class HybridHaptikaHealthKit : HybridHaptikaHealthKitSpec { + * // ... + * } + * ``` + */ +public typealias HybridHaptikaHealthKitSpec = HybridHaptikaHealthKitSpec_protocol & HybridHaptikaHealthKitSpec_base diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaHealthKitSpec_cxx.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaHealthKitSpec_cxx.swift new file mode 100644 index 0000000..f0b0cc5 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaHealthKitSpec_cxx.swift @@ -0,0 +1,201 @@ +/// +/// HybridHaptikaHealthKitSpec_cxx.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * A class implementation that bridges HybridHaptikaHealthKitSpec over to C++. + * In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined. + * + * Also, some Swift types need to be bridged with special handling: + * - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330) + * - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper + * - Throwing methods need to be wrapped with a Result type, as exceptions cannot be propagated to C++ + */ +open class HybridHaptikaHealthKitSpec_cxx { + /** + * The Swift <> C++ bridge's namespace (`margelo::nitro::haptika::ppg::bridge::swift`) + * from `HaptikaPPG-Swift-Cxx-Bridge.hpp`. + * This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift. + */ + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Holds an instance of the `HybridHaptikaHealthKitSpec` Swift protocol. + */ + private var __implementation: any HybridHaptikaHealthKitSpec + + /** + * Holds a weak pointer to the C++ class that wraps the Swift class. + */ + private var __cxxPart: bridge.std__weak_ptr_HybridHaptikaHealthKitSpec_ + + /** + * Create a new `HybridHaptikaHealthKitSpec_cxx` that wraps the given `HybridHaptikaHealthKitSpec`. + * All properties and methods bridge to C++ types. + */ + public init(_ implementation: any HybridHaptikaHealthKitSpec) { + self.__implementation = implementation + self.__cxxPart = .init() + /* no base class */ + } + + /** + * Get the actual `HybridHaptikaHealthKitSpec` instance this class wraps. + */ + @inline(__always) + public func getHybridHaptikaHealthKitSpec() -> any HybridHaptikaHealthKitSpec { + return __implementation + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `HybridHaptikaHealthKitSpec_cxx`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridHaptikaHealthKitSpec_cxx { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } + + /** + * Gets (or creates) the C++ part of this Hybrid Object. + * The C++ part is a `std::shared_ptr`. + */ + public func getCxxPart() -> bridge.std__shared_ptr_HybridHaptikaHealthKitSpec_ { + let cachedCxxPart = self.__cxxPart.lock() + if Bool(fromCxx: cachedCxxPart) { + return cachedCxxPart + } else { + let newCxxPart = bridge.create_std__shared_ptr_HybridHaptikaHealthKitSpec_(self.toUnsafe()) + __cxxPart = bridge.weakify_std__shared_ptr_HybridHaptikaHealthKitSpec_(newCxxPart) + return newCxxPart + } + } + + + + /** + * Get the memory size of the Swift class (plus size of any other allocations) + * so the JS VM can properly track it and garbage-collect the JS object if needed. + */ + @inline(__always) + public var memorySize: Int { + return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize + } + + /** + * Compares this object with the given [other] object for reference equality. + */ + @inline(__always) + public func equals(other: HybridHaptikaHealthKitSpec_cxx) -> Bool { + return self.__implementation === other.__implementation + } + + /** + * Call dispose() on the Swift class. + * This _may_ be called manually from JS. + */ + @inline(__always) + public func dispose() { + self.__implementation.dispose() + } + + /** + * Call toString() on the Swift class. + */ + @inline(__always) + public func toString() -> String { + return self.__implementation.toString() + } + + // Properties + + + // Methods + @inline(__always) + public final func isAvailable() -> bridge.Result_bool_ { + do { + let __result = try self.__implementation.isAvailable() + let __resultCpp = __result + return bridge.create_Result_bool_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_bool_(__exceptionPtr) + } + } + + @inline(__always) + public final func requestReadAuthorization() -> bridge.Result_std__shared_ptr_Promise_bool___ { + do { + let __result = try self.__implementation.requestReadAuthorization() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_bool__ in + let __promise = bridge.create_std__shared_ptr_Promise_bool__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_bool__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_bool___(__exceptionPtr) + } + } + + @inline(__always) + public final func fetchContext(lookbackDays: Double) -> bridge.Result_std__shared_ptr_Promise_NativeHealthKitContext___ { + do { + let __result = try self.__implementation.fetchContext(lookbackDays: lookbackDays) + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_NativeHealthKitContext__ in + let __promise = bridge.create_std__shared_ptr_Promise_NativeHealthKitContext__() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_NativeHealthKitContext__(__promise) + __result + .then({ __result in __promiseHolder.resolve(__result) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_NativeHealthKitContext___(__exceptionPtr) + } + } + + @inline(__always) + public final func fetchLatestHeartbeatSeries() -> bridge.Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____ { + do { + let __result = try self.__implementation.fetchLatestHeartbeatSeries() + let __resultCpp = { () -> bridge.std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___ in + let __promise = bridge.create_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___() + let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary___(__promise) + __result + .then({ __result in __promiseHolder.resolve({ () -> bridge.std__optional_NativeHeartbeatSeriesSummary_ in + if let __unwrappedValue = __result { + return bridge.create_std__optional_NativeHeartbeatSeriesSummary_(__unwrappedValue) + } else { + return .init() + } + }()) }) + .catch({ __error in __promiseHolder.reject(__error.toCpp()) }) + return __promise + }() + return bridge.create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__shared_ptr_Promise_std__optional_NativeHeartbeatSeriesSummary____(__exceptionPtr) + } + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaPPGFrameProcessorSpec.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaPPGFrameProcessorSpec.swift new file mode 100644 index 0000000..3e8901a --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaPPGFrameProcessorSpec.swift @@ -0,0 +1,60 @@ +/// +/// HybridHaptikaPPGFrameProcessorSpec.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import VisionCamera +import NitroModules + +/// See ``HybridHaptikaPPGFrameProcessorSpec`` +public protocol HybridHaptikaPPGFrameProcessorSpec_protocol: HybridObject { + // Properties + + + // Methods + func startMeasurement(configuration: NativePPGConfiguration) throws -> Void + func processFrame(frame: (any HybridFrameSpec)) throws -> PPGFrameUpdate? + func finishMeasurement() throws -> NativePPGProcessingResult + func cancelMeasurement() throws -> Void + func getThermalState() throws -> ThermalState +} + +public extension HybridHaptikaPPGFrameProcessorSpec_protocol { + /// Default implementation of ``HybridObject.toString`` + func toString() -> String { + return "[HybridObject HaptikaPPGFrameProcessor]" + } +} + +/// See ``HybridHaptikaPPGFrameProcessorSpec`` +open class HybridHaptikaPPGFrameProcessorSpec_base { + private weak var cxxWrapper: HybridHaptikaPPGFrameProcessorSpec_cxx? = nil + public init() { } + public func getCxxWrapper() -> HybridHaptikaPPGFrameProcessorSpec_cxx { + #if DEBUG + guard self is any HybridHaptikaPPGFrameProcessorSpec else { + fatalError("`self` is not a `HybridHaptikaPPGFrameProcessorSpec`! Did you accidentally inherit from `HybridHaptikaPPGFrameProcessorSpec_base` instead of `HybridHaptikaPPGFrameProcessorSpec`?") + } + #endif + if let cxxWrapper = self.cxxWrapper { + return cxxWrapper + } else { + let cxxWrapper = HybridHaptikaPPGFrameProcessorSpec_cxx(self as! any HybridHaptikaPPGFrameProcessorSpec) + self.cxxWrapper = cxxWrapper + return cxxWrapper + } + } +} + +/** + * A Swift base-protocol representing the HaptikaPPGFrameProcessor HybridObject. + * Implement this protocol to create Swift-based instances of HaptikaPPGFrameProcessor. + * ```swift + * class HybridHaptikaPPGFrameProcessor : HybridHaptikaPPGFrameProcessorSpec { + * // ... + * } + * ``` + */ +public typealias HybridHaptikaPPGFrameProcessorSpec = HybridHaptikaPPGFrameProcessorSpec_protocol & HybridHaptikaPPGFrameProcessorSpec_base diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaPPGFrameProcessorSpec_cxx.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaPPGFrameProcessorSpec_cxx.swift new file mode 100644 index 0000000..60e6883 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/HybridHaptikaPPGFrameProcessorSpec_cxx.swift @@ -0,0 +1,195 @@ +/// +/// HybridHaptikaPPGFrameProcessorSpec_cxx.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules +import VisionCamera + +/** + * A class implementation that bridges HybridHaptikaPPGFrameProcessorSpec over to C++. + * In C++, we cannot use Swift protocols - so we need to wrap it in a class to make it strongly defined. + * + * Also, some Swift types need to be bridged with special handling: + * - Enums need to be wrapped in Structs, otherwise they cannot be accessed bi-directionally (Swift bug: https://github.com/swiftlang/swift/issues/75330) + * - Other HybridObjects need to be wrapped/unwrapped from the Swift TCxx wrapper + * - Throwing methods need to be wrapped with a Result type, as exceptions cannot be propagated to C++ + */ +open class HybridHaptikaPPGFrameProcessorSpec_cxx { + /** + * The Swift <> C++ bridge's namespace (`margelo::nitro::haptika::ppg::bridge::swift`) + * from `HaptikaPPG-Swift-Cxx-Bridge.hpp`. + * This contains specialized C++ templates, and C++ helper functions that can be accessed from Swift. + */ + public typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Holds an instance of the `HybridHaptikaPPGFrameProcessorSpec` Swift protocol. + */ + private var __implementation: any HybridHaptikaPPGFrameProcessorSpec + + /** + * Holds a weak pointer to the C++ class that wraps the Swift class. + */ + private var __cxxPart: bridge.std__weak_ptr_HybridHaptikaPPGFrameProcessorSpec_ + + /** + * Create a new `HybridHaptikaPPGFrameProcessorSpec_cxx` that wraps the given `HybridHaptikaPPGFrameProcessorSpec`. + * All properties and methods bridge to C++ types. + */ + public init(_ implementation: any HybridHaptikaPPGFrameProcessorSpec) { + self.__implementation = implementation + self.__cxxPart = .init() + /* no base class */ + } + + /** + * Get the actual `HybridHaptikaPPGFrameProcessorSpec` instance this class wraps. + */ + @inline(__always) + public func getHybridHaptikaPPGFrameProcessorSpec() -> any HybridHaptikaPPGFrameProcessorSpec { + return __implementation + } + + /** + * Casts this instance to a retained unsafe raw pointer. + * This acquires one additional strong reference on the object! + */ + public func toUnsafe() -> UnsafeMutableRawPointer { + return Unmanaged.passRetained(self).toOpaque() + } + + /** + * Casts an unsafe pointer to a `HybridHaptikaPPGFrameProcessorSpec_cxx`. + * The pointer has to be a retained opaque `Unmanaged`. + * This removes one strong reference from the object! + */ + public class func fromUnsafe(_ pointer: UnsafeMutableRawPointer) -> HybridHaptikaPPGFrameProcessorSpec_cxx { + return Unmanaged.fromOpaque(pointer).takeRetainedValue() + } + + /** + * Gets (or creates) the C++ part of this Hybrid Object. + * The C++ part is a `std::shared_ptr`. + */ + public func getCxxPart() -> bridge.std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_ { + let cachedCxxPart = self.__cxxPart.lock() + if Bool(fromCxx: cachedCxxPart) { + return cachedCxxPart + } else { + let newCxxPart = bridge.create_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(self.toUnsafe()) + __cxxPart = bridge.weakify_std__shared_ptr_HybridHaptikaPPGFrameProcessorSpec_(newCxxPart) + return newCxxPart + } + } + + + + /** + * Get the memory size of the Swift class (plus size of any other allocations) + * so the JS VM can properly track it and garbage-collect the JS object if needed. + */ + @inline(__always) + public var memorySize: Int { + return MemoryHelper.getSizeOf(self.__implementation) + self.__implementation.memorySize + } + + /** + * Compares this object with the given [other] object for reference equality. + */ + @inline(__always) + public func equals(other: HybridHaptikaPPGFrameProcessorSpec_cxx) -> Bool { + return self.__implementation === other.__implementation + } + + /** + * Call dispose() on the Swift class. + * This _may_ be called manually from JS. + */ + @inline(__always) + public func dispose() { + self.__implementation.dispose() + } + + /** + * Call toString() on the Swift class. + */ + @inline(__always) + public func toString() -> String { + return self.__implementation.toString() + } + + // Properties + + + // Methods + @inline(__always) + public final func startMeasurement(configuration: NativePPGConfiguration) -> bridge.Result_void_ { + do { + try self.__implementation.startMeasurement(configuration: configuration) + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func processFrame(frame: bridge.std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_) -> bridge.Result_std__optional_PPGFrameUpdate__ { + do { + let __result = try self.__implementation.processFrame(frame: { () -> any HybridFrameSpec in + let __unsafePointer = bridge.get_std__shared_ptr_margelo__nitro__camera__HybridFrameSpec_(frame) + let __instance = HybridFrameSpec_cxx.fromUnsafe(__unsafePointer) + return __instance.getHybridFrameSpec() + }()) + let __resultCpp = { () -> bridge.std__optional_PPGFrameUpdate_ in + if let __unwrappedValue = __result { + return bridge.create_std__optional_PPGFrameUpdate_(__unwrappedValue) + } else { + return .init() + } + }() + return bridge.create_Result_std__optional_PPGFrameUpdate__(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_std__optional_PPGFrameUpdate__(__exceptionPtr) + } + } + + @inline(__always) + public final func finishMeasurement() -> bridge.Result_NativePPGProcessingResult_ { + do { + let __result = try self.__implementation.finishMeasurement() + let __resultCpp = __result + return bridge.create_Result_NativePPGProcessingResult_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_NativePPGProcessingResult_(__exceptionPtr) + } + } + + @inline(__always) + public final func cancelMeasurement() -> bridge.Result_void_ { + do { + try self.__implementation.cancelMeasurement() + return bridge.create_Result_void_() + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_void_(__exceptionPtr) + } + } + + @inline(__always) + public final func getThermalState() -> bridge.Result_ThermalState_ { + do { + let __result = try self.__implementation.getThermalState() + let __resultCpp = __result + return bridge.create_Result_ThermalState_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_ThermalState_(__exceptionPtr) + } + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeAcceptedBeat.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeAcceptedBeat.swift new file mode 100644 index 0000000..c9cd708 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeAcceptedBeat.swift @@ -0,0 +1,44 @@ +/// +/// NativeAcceptedBeat.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeAcceptedBeat`, backed by a C++ struct. + */ +public typealias NativeAcceptedBeat = margelo.nitro.haptika.ppg.NativeAcceptedBeat + +public extension NativeAcceptedBeat { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeAcceptedBeat`. + */ + init(sequence: Double, timestampSeconds: Double, intervalMs: Double, confidence: Double) { + self.init(sequence, timestampSeconds, intervalMs, confidence) + } + + @inline(__always) + var sequence: Double { + return self.__sequence + } + + @inline(__always) + var timestampSeconds: Double { + return self.__timestampSeconds + } + + @inline(__always) + var intervalMs: Double { + return self.__intervalMs + } + + @inline(__always) + var confidence: Double { + return self.__confidence + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeBPMEstimate.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeBPMEstimate.swift new file mode 100644 index 0000000..bcc69d5 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeBPMEstimate.swift @@ -0,0 +1,116 @@ +/// +/// NativeBPMEstimate.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeBPMEstimate`, backed by a C++ struct. + */ +public typealias NativeBPMEstimate = margelo.nitro.haptika.ppg.NativeBPMEstimate + +public extension NativeBPMEstimate { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeBPMEstimate`. + */ + init(bpm: Double?, temporalBpm: Double?, frequencyBpm: Double?, methodAgreementBpm: Double?, validPeakCount: Double, rejectedPeakCount: Double, confidence: Double, selectedChannel: String) { + self.init({ () -> bridge.std__optional_double_ in + if let __unwrappedValue = bpm { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = temporalBpm { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = frequencyBpm { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = methodAgreementBpm { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), validPeakCount, rejectedPeakCount, confidence, std.string(selectedChannel)) + } + + @inline(__always) + var bpm: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__bpm) { + let __unwrapped = bridge.get_std__optional_double_(self.__bpm) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var temporalBpm: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__temporalBpm) { + let __unwrapped = bridge.get_std__optional_double_(self.__temporalBpm) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var frequencyBpm: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__frequencyBpm) { + let __unwrapped = bridge.get_std__optional_double_(self.__frequencyBpm) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var methodAgreementBpm: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__methodAgreementBpm) { + let __unwrapped = bridge.get_std__optional_double_(self.__methodAgreementBpm) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var validPeakCount: Double { + return self.__validPeakCount + } + + @inline(__always) + var rejectedPeakCount: Double { + return self.__rejectedPeakCount + } + + @inline(__always) + var confidence: Double { + return self.__confidence + } + + @inline(__always) + var selectedChannel: String { + return String(self.__selectedChannel) + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeCardiacMirrorHeartbeat.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeCardiacMirrorHeartbeat.swift new file mode 100644 index 0000000..cf6736a --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeCardiacMirrorHeartbeat.swift @@ -0,0 +1,34 @@ +/// +/// NativeCardiacMirrorHeartbeat.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeCardiacMirrorHeartbeat`, backed by a C++ struct. + */ +public typealias NativeCardiacMirrorHeartbeat = margelo.nitro.haptika.ppg.NativeCardiacMirrorHeartbeat + +public extension NativeCardiacMirrorHeartbeat { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeCardiacMirrorHeartbeat`. + */ + init(timestampSeconds: Double, bpm: Double) { + self.init(timestampSeconds, bpm) + } + + @inline(__always) + var timestampSeconds: Double { + return self.__timestampSeconds + } + + @inline(__always) + var bpm: Double { + return self.__bpm + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeCardiacMirrorPatternInfo.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeCardiacMirrorPatternInfo.swift new file mode 100644 index 0000000..8ec815e --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeCardiacMirrorPatternInfo.swift @@ -0,0 +1,39 @@ +/// +/// NativeCardiacMirrorPatternInfo.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeCardiacMirrorPatternInfo`, backed by a C++ struct. + */ +public typealias NativeCardiacMirrorPatternInfo = margelo.nitro.haptika.ppg.NativeCardiacMirrorPatternInfo + +public extension NativeCardiacMirrorPatternInfo { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeCardiacMirrorPatternInfo`. + */ + init(id: String, version: String, latencyBudgetMs: Double) { + self.init(std.string(id), std.string(version), latencyBudgetMs) + } + + @inline(__always) + var id: String { + return String(self.__id) + } + + @inline(__always) + var version: String { + return String(self.__version) + } + + @inline(__always) + var latencyBudgetMs: Double { + return self.__latencyBudgetMs + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHealthKitContext.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHealthKitContext.swift new file mode 100644 index 0000000..85f1300 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHealthKitContext.swift @@ -0,0 +1,77 @@ +/// +/// NativeHealthKitContext.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeHealthKitContext`, backed by a C++ struct. + */ +public typealias NativeHealthKitContext = margelo.nitro.haptika.ppg.NativeHealthKitContext + +public extension NativeHealthKitContext { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeHealthKitContext`. + */ + init(available: Bool, authorized: Bool, latestRestingHeartRate: NativeHealthMetricSample?, latestHrvSdnn: NativeHealthMetricSample?, recentHeartRateSamples: [NativeHealthMetricSample], contextConfidence: Double, fetchedAt: String) { + self.init(available, authorized, { () -> bridge.std__optional_NativeHealthMetricSample_ in + if let __unwrappedValue = latestRestingHeartRate { + return bridge.create_std__optional_NativeHealthMetricSample_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_NativeHealthMetricSample_ in + if let __unwrappedValue = latestHrvSdnn { + return bridge.create_std__optional_NativeHealthMetricSample_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__vector_NativeHealthMetricSample_ in + var __vector = bridge.create_std__vector_NativeHealthMetricSample_(recentHeartRateSamples.count) + for __item in recentHeartRateSamples { + __vector.push_back(__item) + } + return __vector + }(), contextConfidence, std.string(fetchedAt)) + } + + @inline(__always) + var available: Bool { + return self.__available + } + + @inline(__always) + var authorized: Bool { + return self.__authorized + } + + @inline(__always) + var latestRestingHeartRate: NativeHealthMetricSample? { + return self.__latestRestingHeartRate.value + } + + @inline(__always) + var latestHrvSdnn: NativeHealthMetricSample? { + return self.__latestHrvSdnn.value + } + + @inline(__always) + var recentHeartRateSamples: [NativeHealthMetricSample] { + return self.__recentHeartRateSamples.map({ __item in __item }) + } + + @inline(__always) + var contextConfidence: Double { + return self.__contextConfidence + } + + @inline(__always) + var fetchedAt: String { + return String(self.__fetchedAt) + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHealthMetricSample.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHealthMetricSample.swift new file mode 100644 index 0000000..63b17b6 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHealthMetricSample.swift @@ -0,0 +1,103 @@ +/// +/// NativeHealthMetricSample.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeHealthMetricSample`, backed by a C++ struct. + */ +public typealias NativeHealthMetricSample = margelo.nitro.haptika.ppg.NativeHealthMetricSample + +public extension NativeHealthMetricSample { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeHealthMetricSample`. + */ + init(type: HealthMetricType, value: Double, unit: String, startDate: String, endDate: String, sourceName: String?, sourceBundleIdentifier: String?, deviceName: String?) { + self.init(type, value, std.string(unit), std.string(startDate), std.string(endDate), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = sourceName { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = sourceBundleIdentifier { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = deviceName { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }()) + } + + @inline(__always) + var type: HealthMetricType { + return self.__type + } + + @inline(__always) + var value: Double { + return self.__value + } + + @inline(__always) + var unit: String { + return String(self.__unit) + } + + @inline(__always) + var startDate: String { + return String(self.__startDate) + } + + @inline(__always) + var endDate: String { + return String(self.__endDate) + } + + @inline(__always) + var sourceName: String? { + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__sourceName) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__sourceName) + return String(__unwrapped) + } else { + return nil + } + }() + } + + @inline(__always) + var sourceBundleIdentifier: String? { + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__sourceBundleIdentifier) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__sourceBundleIdentifier) + return String(__unwrapped) + } else { + return nil + } + }() + } + + @inline(__always) + var deviceName: String? { + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__deviceName) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__deviceName) + return String(__unwrapped) + } else { + return nil + } + }() + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHeartbeatSeriesSummary.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHeartbeatSeriesSummary.swift new file mode 100644 index 0000000..3aa86c2 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeHeartbeatSeriesSummary.swift @@ -0,0 +1,92 @@ +/// +/// NativeHeartbeatSeriesSummary.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeHeartbeatSeriesSummary`, backed by a C++ struct. + */ +public typealias NativeHeartbeatSeriesSummary = margelo.nitro.haptika.ppg.NativeHeartbeatSeriesSummary + +public extension NativeHeartbeatSeriesSummary { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeHeartbeatSeriesSummary`. + */ + init(startDate: String, endDate: String, sourceName: String?, sourceBundleIdentifier: String?, heartbeatTimesSinceSeriesStartSeconds: [Double], precededByGapIndexes: [Double]) { + self.init(std.string(startDate), std.string(endDate), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = sourceName { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = sourceBundleIdentifier { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), { () -> bridge.std__vector_double_ in + var __vector = bridge.create_std__vector_double_(heartbeatTimesSinceSeriesStartSeconds.count) + for __item in heartbeatTimesSinceSeriesStartSeconds { + __vector.push_back(__item) + } + return __vector + }(), { () -> bridge.std__vector_double_ in + var __vector = bridge.create_std__vector_double_(precededByGapIndexes.count) + for __item in precededByGapIndexes { + __vector.push_back(__item) + } + return __vector + }()) + } + + @inline(__always) + var startDate: String { + return String(self.__startDate) + } + + @inline(__always) + var endDate: String { + return String(self.__endDate) + } + + @inline(__always) + var sourceName: String? { + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__sourceName) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__sourceName) + return String(__unwrapped) + } else { + return nil + } + }() + } + + @inline(__always) + var sourceBundleIdentifier: String? { + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__sourceBundleIdentifier) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__sourceBundleIdentifier) + return String(__unwrapped) + } else { + return nil + } + }() + } + + @inline(__always) + var heartbeatTimesSinceSeriesStartSeconds: [Double] { + return self.__heartbeatTimesSinceSeriesStartSeconds.map({ __item in __item }) + } + + @inline(__always) + var precededByGapIndexes: [Double] { + return self.__precededByGapIndexes.map({ __item in __item }) + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePPGConfiguration.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePPGConfiguration.swift new file mode 100644 index 0000000..d3ceabf --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePPGConfiguration.swift @@ -0,0 +1,64 @@ +/// +/// NativePPGConfiguration.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativePPGConfiguration`, backed by a C++ struct. + */ +public typealias NativePPGConfiguration = margelo.nitro.haptika.ppg.NativePPGConfiguration + +public extension NativePPGConfiguration { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativePPGConfiguration`. + */ + init(sessionPurpose: PPGSessionPurpose, targetSampleRate: Double, minimumFrequencyHz: Double, maximumFrequencyHz: Double, stabilizationSeconds: Double, measurementSeconds: Double, emitEveryFrames: Double, minimumContactConfidence: Double) { + self.init(sessionPurpose, targetSampleRate, minimumFrequencyHz, maximumFrequencyHz, stabilizationSeconds, measurementSeconds, emitEveryFrames, minimumContactConfidence) + } + + @inline(__always) + var sessionPurpose: PPGSessionPurpose { + return self.__sessionPurpose + } + + @inline(__always) + var targetSampleRate: Double { + return self.__targetSampleRate + } + + @inline(__always) + var minimumFrequencyHz: Double { + return self.__minimumFrequencyHz + } + + @inline(__always) + var maximumFrequencyHz: Double { + return self.__maximumFrequencyHz + } + + @inline(__always) + var stabilizationSeconds: Double { + return self.__stabilizationSeconds + } + + @inline(__always) + var measurementSeconds: Double { + return self.__measurementSeconds + } + + @inline(__always) + var emitEveryFrames: Double { + return self.__emitEveryFrames + } + + @inline(__always) + var minimumContactConfidence: Double { + return self.__minimumContactConfidence + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePPGProcessingResult.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePPGProcessingResult.swift new file mode 100644 index 0000000..3217919 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePPGProcessingResult.swift @@ -0,0 +1,82 @@ +/// +/// NativePPGProcessingResult.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativePPGProcessingResult`, backed by a C++ struct. + */ +public typealias NativePPGProcessingResult = margelo.nitro.haptika.ppg.NativePPGProcessingResult + +public extension NativePPGProcessingResult { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativePPGProcessingResult`. + */ + init(valid: Bool, invalidReason: String?, bpm: NativeBPMEstimate, prv: NativePRVMetrics, quality: NativeSignalQualityResult, startedAtMonotonicSeconds: Double, endedAtMonotonicSeconds: Double, rawSampleCount: Double, resampledSampleCount: Double) { + self.init(valid, { () -> bridge.std__optional_std__string_ in + if let __unwrappedValue = invalidReason { + return bridge.create_std__optional_std__string_(std.string(__unwrappedValue)) + } else { + return .init() + } + }(), bpm, prv, quality, startedAtMonotonicSeconds, endedAtMonotonicSeconds, rawSampleCount, resampledSampleCount) + } + + @inline(__always) + var valid: Bool { + return self.__valid + } + + @inline(__always) + var invalidReason: String? { + return { () -> String? in + if bridge.has_value_std__optional_std__string_(self.__invalidReason) { + let __unwrapped = bridge.get_std__optional_std__string_(self.__invalidReason) + return String(__unwrapped) + } else { + return nil + } + }() + } + + @inline(__always) + var bpm: NativeBPMEstimate { + return self.__bpm + } + + @inline(__always) + var prv: NativePRVMetrics { + return self.__prv + } + + @inline(__always) + var quality: NativeSignalQualityResult { + return self.__quality + } + + @inline(__always) + var startedAtMonotonicSeconds: Double { + return self.__startedAtMonotonicSeconds + } + + @inline(__always) + var endedAtMonotonicSeconds: Double { + return self.__endedAtMonotonicSeconds + } + + @inline(__always) + var rawSampleCount: Double { + return self.__rawSampleCount + } + + @inline(__always) + var resampledSampleCount: Double { + return self.__resampledSampleCount + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePRVMetrics.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePRVMetrics.swift new file mode 100644 index 0000000..6bb1ec7 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativePRVMetrics.swift @@ -0,0 +1,206 @@ +/// +/// NativePRVMetrics.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativePRVMetrics`, backed by a C++ struct. + */ +public typealias NativePRVMetrics = margelo.nitro.haptika.ppg.NativePRVMetrics + +public extension NativePRVMetrics { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativePRVMetrics`. + */ + init(estimatedRmssdMs: Double?, estimatedSdnnMs: Double?, meanIpiMs: Double?, medianIpiMs: Double?, pnn50Percent: Double?, rangeIpiMs: Double?, modeIpiMs: Double?, amo50Percent: Double?, coefficientOfVariation: Double?, validIntervalCount: Double, rejectedIntervalCount: Double, rejectedIntervalRatio: Double, reliable: Bool) { + self.init({ () -> bridge.std__optional_double_ in + if let __unwrappedValue = estimatedRmssdMs { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = estimatedSdnnMs { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = meanIpiMs { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = medianIpiMs { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = pnn50Percent { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = rangeIpiMs { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = modeIpiMs { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = amo50Percent { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = coefficientOfVariation { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), validIntervalCount, rejectedIntervalCount, rejectedIntervalRatio, reliable) + } + + @inline(__always) + var estimatedRmssdMs: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__estimatedRmssdMs) { + let __unwrapped = bridge.get_std__optional_double_(self.__estimatedRmssdMs) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var estimatedSdnnMs: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__estimatedSdnnMs) { + let __unwrapped = bridge.get_std__optional_double_(self.__estimatedSdnnMs) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var meanIpiMs: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__meanIpiMs) { + let __unwrapped = bridge.get_std__optional_double_(self.__meanIpiMs) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var medianIpiMs: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__medianIpiMs) { + let __unwrapped = bridge.get_std__optional_double_(self.__medianIpiMs) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var pnn50Percent: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__pnn50Percent) { + let __unwrapped = bridge.get_std__optional_double_(self.__pnn50Percent) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var rangeIpiMs: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__rangeIpiMs) { + let __unwrapped = bridge.get_std__optional_double_(self.__rangeIpiMs) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var modeIpiMs: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__modeIpiMs) { + let __unwrapped = bridge.get_std__optional_double_(self.__modeIpiMs) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var amo50Percent: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__amo50Percent) { + let __unwrapped = bridge.get_std__optional_double_(self.__amo50Percent) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var coefficientOfVariation: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__coefficientOfVariation) { + let __unwrapped = bridge.get_std__optional_double_(self.__coefficientOfVariation) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var validIntervalCount: Double { + return self.__validIntervalCount + } + + @inline(__always) + var rejectedIntervalCount: Double { + return self.__rejectedIntervalCount + } + + @inline(__always) + var rejectedIntervalRatio: Double { + return self.__rejectedIntervalRatio + } + + @inline(__always) + var reliable: Bool { + return self.__reliable + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeSignalQualityResult.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeSignalQualityResult.swift new file mode 100644 index 0000000..1b4a975 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/NativeSignalQualityResult.swift @@ -0,0 +1,103 @@ +/// +/// NativeSignalQualityResult.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `NativeSignalQualityResult`, backed by a C++ struct. + */ +public typealias NativeSignalQualityResult = margelo.nitro.haptika.ppg.NativeSignalQualityResult + +public extension NativeSignalQualityResult { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `NativeSignalQualityResult`. + */ + init(score: Double, level: String, usableDurationSeconds: Double, droppedFrameRatio: Double, saturationRatio: Double, rejectedIntervalRatio: Double, signalToNoiseRatio: Double?, motionArtifactRatio: Double, methodAgreementScore: Double, timestampJitterRatio: Double, longestGapSeconds: Double, issues: [String]) { + self.init(score, std.string(level), usableDurationSeconds, droppedFrameRatio, saturationRatio, rejectedIntervalRatio, { () -> bridge.std__optional_double_ in + if let __unwrappedValue = signalToNoiseRatio { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), motionArtifactRatio, methodAgreementScore, timestampJitterRatio, longestGapSeconds, { () -> bridge.std__vector_std__string_ in + var __vector = bridge.create_std__vector_std__string_(issues.count) + for __item in issues { + __vector.push_back(std.string(__item)) + } + return __vector + }()) + } + + @inline(__always) + var score: Double { + return self.__score + } + + @inline(__always) + var level: String { + return String(self.__level) + } + + @inline(__always) + var usableDurationSeconds: Double { + return self.__usableDurationSeconds + } + + @inline(__always) + var droppedFrameRatio: Double { + return self.__droppedFrameRatio + } + + @inline(__always) + var saturationRatio: Double { + return self.__saturationRatio + } + + @inline(__always) + var rejectedIntervalRatio: Double { + return self.__rejectedIntervalRatio + } + + @inline(__always) + var signalToNoiseRatio: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__signalToNoiseRatio) { + let __unwrapped = bridge.get_std__optional_double_(self.__signalToNoiseRatio) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var motionArtifactRatio: Double { + return self.__motionArtifactRatio + } + + @inline(__always) + var methodAgreementScore: Double { + return self.__methodAgreementScore + } + + @inline(__always) + var timestampJitterRatio: Double { + return self.__timestampJitterRatio + } + + @inline(__always) + var longestGapSeconds: Double { + return self.__longestGapSeconds + } + + @inline(__always) + var issues: [String] { + return self.__issues.map({ __item in String(__item) }) + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/PPGFrameUpdate.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/PPGFrameUpdate.swift new file mode 100644 index 0000000..1c66e20 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/PPGFrameUpdate.swift @@ -0,0 +1,149 @@ +/// +/// PPGFrameUpdate.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +import NitroModules + +/** + * Represents an instance of `PPGFrameUpdate`, backed by a C++ struct. + */ +public typealias PPGFrameUpdate = margelo.nitro.haptika.ppg.PPGFrameUpdate + +public extension PPGFrameUpdate { + private typealias bridge = margelo.nitro.haptika.ppg.bridge.swift + + /** + * Create a new instance of `PPGFrameUpdate`. + */ + init(timestampSeconds: Double, contactState: FingerContactState, contactConfidence: Double, signalLevel: Double, saturationRatio: Double, motionArtifactScore: Double, collectedSampleCount: Double, usableDurationSeconds: Double, droppedFrameCount: Double, thermalState: ThermalState, waveformValue: Double, liveBpm: Double?, liveIpiMs: Double?, beatSequence: Double, acceptedBeats: [NativeAcceptedBeat], liveWindowQuality: Double?) { + self.init(timestampSeconds, contactState, contactConfidence, signalLevel, saturationRatio, motionArtifactScore, collectedSampleCount, usableDurationSeconds, droppedFrameCount, thermalState, waveformValue, { () -> bridge.std__optional_double_ in + if let __unwrappedValue = liveBpm { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = liveIpiMs { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }(), beatSequence, { () -> bridge.std__vector_NativeAcceptedBeat_ in + var __vector = bridge.create_std__vector_NativeAcceptedBeat_(acceptedBeats.count) + for __item in acceptedBeats { + __vector.push_back(__item) + } + return __vector + }(), { () -> bridge.std__optional_double_ in + if let __unwrappedValue = liveWindowQuality { + return bridge.create_std__optional_double_(__unwrappedValue) + } else { + return .init() + } + }()) + } + + @inline(__always) + var timestampSeconds: Double { + return self.__timestampSeconds + } + + @inline(__always) + var contactState: FingerContactState { + return self.__contactState + } + + @inline(__always) + var contactConfidence: Double { + return self.__contactConfidence + } + + @inline(__always) + var signalLevel: Double { + return self.__signalLevel + } + + @inline(__always) + var saturationRatio: Double { + return self.__saturationRatio + } + + @inline(__always) + var motionArtifactScore: Double { + return self.__motionArtifactScore + } + + @inline(__always) + var collectedSampleCount: Double { + return self.__collectedSampleCount + } + + @inline(__always) + var usableDurationSeconds: Double { + return self.__usableDurationSeconds + } + + @inline(__always) + var droppedFrameCount: Double { + return self.__droppedFrameCount + } + + @inline(__always) + var thermalState: ThermalState { + return self.__thermalState + } + + @inline(__always) + var waveformValue: Double { + return self.__waveformValue + } + + @inline(__always) + var liveBpm: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__liveBpm) { + let __unwrapped = bridge.get_std__optional_double_(self.__liveBpm) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var liveIpiMs: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__liveIpiMs) { + let __unwrapped = bridge.get_std__optional_double_(self.__liveIpiMs) + return __unwrapped + } else { + return nil + } + }() + } + + @inline(__always) + var beatSequence: Double { + return self.__beatSequence + } + + @inline(__always) + var acceptedBeats: [NativeAcceptedBeat] { + return self.__acceptedBeats.map({ __item in __item }) + } + + @inline(__always) + var liveWindowQuality: Double? { + return { () -> Double? in + if bridge.has_value_std__optional_double_(self.__liveWindowQuality) { + let __unwrapped = bridge.get_std__optional_double_(self.__liveWindowQuality) + return __unwrapped + } else { + return nil + } + }() + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/PPGSessionPurpose.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/PPGSessionPurpose.swift new file mode 100644 index 0000000..21ef7cf --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/PPGSessionPurpose.swift @@ -0,0 +1,40 @@ +/// +/// PPGSessionPurpose.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +/** + * Represents the JS union `PPGSessionPurpose`, backed by a C++ enum. + */ +public typealias PPGSessionPurpose = margelo.nitro.haptika.ppg.PPGSessionPurpose + +public extension PPGSessionPurpose { + /** + * Get a PPGSessionPurpose for the given String value, or + * return `nil` if the given value was invalid/unknown. + */ + init?(fromString string: String) { + switch string { + case "spotMeasurement": + self = .spotmeasurement + case "coherence": + self = .coherence + default: + return nil + } + } + + /** + * Get the String value this PPGSessionPurpose represents. + */ + var stringValue: String { + switch self { + case .spotmeasurement: + return "spotMeasurement" + case .coherence: + return "coherence" + } + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/ThermalState.swift b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/ThermalState.swift new file mode 100644 index 0000000..d923867 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/ios/swift/ThermalState.swift @@ -0,0 +1,52 @@ +/// +/// ThermalState.swift +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +/** + * Represents the JS union `ThermalState`, backed by a C++ enum. + */ +public typealias ThermalState = margelo.nitro.haptika.ppg.ThermalState + +public extension ThermalState { + /** + * Get a ThermalState for the given String value, or + * return `nil` if the given value was invalid/unknown. + */ + init?(fromString string: String) { + switch string { + case "nominal": + self = .nominal + case "fair": + self = .fair + case "serious": + self = .serious + case "critical": + self = .critical + case "unknown": + self = .unknown + default: + return nil + } + } + + /** + * Get the String value this ThermalState represents. + */ + var stringValue: String { + switch self { + case .nominal: + return "nominal" + case .fair: + return "fair" + case .serious: + return "serious" + case .critical: + return "critical" + case .unknown: + return "unknown" + } + } +} diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/FingerContactState.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/FingerContactState.hpp new file mode 100644 index 0000000..d9533dc --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/FingerContactState.hpp @@ -0,0 +1,92 @@ +/// +/// FingerContactState.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +namespace margelo::nitro::haptika::ppg { + + /** + * An enum which can be represented as a JavaScript union (FingerContactState). + */ + enum class FingerContactState { + ABSENT SWIFT_NAME(absent) = 0, + ADJUSTING SWIFT_NAME(adjusting) = 1, + VALID SWIFT_NAME(valid) = 2, + TOOMUCHPRESSURE SWIFT_NAME(toomuchpressure) = 3, + OVEREXPOSED SWIFT_NAME(overexposed) = 4, + UNSTABLE SWIFT_NAME(unstable) = 5, + } CLOSED_ENUM; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ FingerContactState <> JS FingerContactState (union) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::FingerContactState fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + std::string unionValue = JSIConverter::fromJSI(runtime, arg); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("absent"): return margelo::nitro::haptika::ppg::FingerContactState::ABSENT; + case hashString("adjusting"): return margelo::nitro::haptika::ppg::FingerContactState::ADJUSTING; + case hashString("valid"): return margelo::nitro::haptika::ppg::FingerContactState::VALID; + case hashString("tooMuchPressure"): return margelo::nitro::haptika::ppg::FingerContactState::TOOMUCHPRESSURE; + case hashString("overexposed"): return margelo::nitro::haptika::ppg::FingerContactState::OVEREXPOSED; + case hashString("unstable"): return margelo::nitro::haptika::ppg::FingerContactState::UNSTABLE; + default: [[unlikely]] + throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum FingerContactState - invalid value!"); + } + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::haptika::ppg::FingerContactState arg) { + switch (arg) { + case margelo::nitro::haptika::ppg::FingerContactState::ABSENT: return JSIConverter::toJSI(runtime, "absent"); + case margelo::nitro::haptika::ppg::FingerContactState::ADJUSTING: return JSIConverter::toJSI(runtime, "adjusting"); + case margelo::nitro::haptika::ppg::FingerContactState::VALID: return JSIConverter::toJSI(runtime, "valid"); + case margelo::nitro::haptika::ppg::FingerContactState::TOOMUCHPRESSURE: return JSIConverter::toJSI(runtime, "tooMuchPressure"); + case margelo::nitro::haptika::ppg::FingerContactState::OVEREXPOSED: return JSIConverter::toJSI(runtime, "overexposed"); + case margelo::nitro::haptika::ppg::FingerContactState::UNSTABLE: return JSIConverter::toJSI(runtime, "unstable"); + default: [[unlikely]] + throw std::invalid_argument("Cannot convert FingerContactState to JS - invalid value: " + + std::to_string(static_cast(arg)) + "!"); + } + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isString()) { + return false; + } + std::string unionValue = JSIConverter::fromJSI(runtime, value); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("absent"): + case hashString("adjusting"): + case hashString("valid"): + case hashString("tooMuchPressure"): + case hashString("overexposed"): + case hashString("unstable"): + return true; + default: + return false; + } + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HealthMetricType.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HealthMetricType.hpp new file mode 100644 index 0000000..4bb22fb --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HealthMetricType.hpp @@ -0,0 +1,80 @@ +/// +/// HealthMetricType.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +namespace margelo::nitro::haptika::ppg { + + /** + * An enum which can be represented as a JavaScript union (HealthMetricType). + */ + enum class HealthMetricType { + HEARTRATE SWIFT_NAME(heartrate) = 0, + RESTINGHEARTRATE SWIFT_NAME(restingheartrate) = 1, + HRVSDNN SWIFT_NAME(hrvsdnn) = 2, + } CLOSED_ENUM; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ HealthMetricType <> JS HealthMetricType (union) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::HealthMetricType fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + std::string unionValue = JSIConverter::fromJSI(runtime, arg); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("heartRate"): return margelo::nitro::haptika::ppg::HealthMetricType::HEARTRATE; + case hashString("restingHeartRate"): return margelo::nitro::haptika::ppg::HealthMetricType::RESTINGHEARTRATE; + case hashString("hrvSdnn"): return margelo::nitro::haptika::ppg::HealthMetricType::HRVSDNN; + default: [[unlikely]] + throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum HealthMetricType - invalid value!"); + } + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::haptika::ppg::HealthMetricType arg) { + switch (arg) { + case margelo::nitro::haptika::ppg::HealthMetricType::HEARTRATE: return JSIConverter::toJSI(runtime, "heartRate"); + case margelo::nitro::haptika::ppg::HealthMetricType::RESTINGHEARTRATE: return JSIConverter::toJSI(runtime, "restingHeartRate"); + case margelo::nitro::haptika::ppg::HealthMetricType::HRVSDNN: return JSIConverter::toJSI(runtime, "hrvSdnn"); + default: [[unlikely]] + throw std::invalid_argument("Cannot convert HealthMetricType to JS - invalid value: " + + std::to_string(static_cast(arg)) + "!"); + } + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isString()) { + return false; + } + std::string unionValue = JSIConverter::fromJSI(runtime, value); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("heartRate"): + case hashString("restingHeartRate"): + case hashString("hrvSdnn"): + return true; + default: + return false; + } + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaCardiacMirrorSpec.cpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaCardiacMirrorSpec.cpp new file mode 100644 index 0000000..de29387 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaCardiacMirrorSpec.cpp @@ -0,0 +1,30 @@ +/// +/// HybridHaptikaCardiacMirrorSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridHaptikaCardiacMirrorSpec.hpp" + +namespace margelo::nitro::haptika::ppg { + + void HybridHaptikaCardiacMirrorSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("isSupported", &HybridHaptikaCardiacMirrorSpec::isSupported); + prototype.registerHybridMethod("patternInfo", &HybridHaptikaCardiacMirrorSpec::patternInfo); + prototype.registerHybridMethod("start", &HybridHaptikaCardiacMirrorSpec::start); + prototype.registerHybridMethod("pulse", &HybridHaptikaCardiacMirrorSpec::pulse); + prototype.registerHybridMethod("stop", &HybridHaptikaCardiacMirrorSpec::stop); + prototype.registerHybridMethod("startWatchSession", &HybridHaptikaCardiacMirrorSpec::startWatchSession); + prototype.registerHybridMethod("watchReachable", &HybridHaptikaCardiacMirrorSpec::watchReachable); + prototype.registerHybridMethod("latestHeartbeat", &HybridHaptikaCardiacMirrorSpec::latestHeartbeat); + prototype.registerHybridMethod("playRegulationProtocol", &HybridHaptikaCardiacMirrorSpec::playRegulationProtocol); + prototype.registerHybridMethod("stopRegulationProtocol", &HybridHaptikaCardiacMirrorSpec::stopRegulationProtocol); + }); + } + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaCardiacMirrorSpec.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaCardiacMirrorSpec.hpp new file mode 100644 index 0000000..4ae7ead --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaCardiacMirrorSpec.hpp @@ -0,0 +1,77 @@ +/// +/// HybridHaptikaCardiacMirrorSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `NativeCardiacMirrorPatternInfo` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorPatternInfo; } +// Forward declaration of `NativeCardiacMirrorHeartbeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeCardiacMirrorHeartbeat; } + +#include "NativeCardiacMirrorPatternInfo.hpp" +#include "NativeCardiacMirrorHeartbeat.hpp" +#include +#include + +namespace margelo::nitro::haptika::ppg { + + using namespace margelo::nitro; + + /** + * An abstract base class for `HaptikaCardiacMirror` + * Inherit this class to create instances of `HybridHaptikaCardiacMirrorSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridHaptikaCardiacMirror: public HybridHaptikaCardiacMirrorSpec { + * public: + * HybridHaptikaCardiacMirror(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridHaptikaCardiacMirrorSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridHaptikaCardiacMirrorSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridHaptikaCardiacMirrorSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual bool isSupported() = 0; + virtual NativeCardiacMirrorPatternInfo patternInfo() = 0; + virtual void start(double targetBpm) = 0; + virtual void pulse() = 0; + virtual void stop() = 0; + virtual void startWatchSession() = 0; + virtual bool watchReachable() = 0; + virtual std::optional latestHeartbeat() = 0; + virtual double playRegulationProtocol(const std::string& kind) = 0; + virtual void stopRegulationProtocol() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "HaptikaCardiacMirror"; + }; + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaHealthKitSpec.cpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaHealthKitSpec.cpp new file mode 100644 index 0000000..2d936eb --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaHealthKitSpec.cpp @@ -0,0 +1,24 @@ +/// +/// HybridHaptikaHealthKitSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridHaptikaHealthKitSpec.hpp" + +namespace margelo::nitro::haptika::ppg { + + void HybridHaptikaHealthKitSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("isAvailable", &HybridHaptikaHealthKitSpec::isAvailable); + prototype.registerHybridMethod("requestReadAuthorization", &HybridHaptikaHealthKitSpec::requestReadAuthorization); + prototype.registerHybridMethod("fetchContext", &HybridHaptikaHealthKitSpec::fetchContext); + prototype.registerHybridMethod("fetchLatestHeartbeatSeries", &HybridHaptikaHealthKitSpec::fetchLatestHeartbeatSeries); + }); + } + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaHealthKitSpec.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaHealthKitSpec.hpp new file mode 100644 index 0000000..27afa18 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaHealthKitSpec.hpp @@ -0,0 +1,71 @@ +/// +/// HybridHaptikaHealthKitSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `NativeHealthKitContext` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthKitContext; } +// Forward declaration of `NativeHeartbeatSeriesSummary` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHeartbeatSeriesSummary; } + +#include +#include "NativeHealthKitContext.hpp" +#include "NativeHeartbeatSeriesSummary.hpp" +#include + +namespace margelo::nitro::haptika::ppg { + + using namespace margelo::nitro; + + /** + * An abstract base class for `HaptikaHealthKit` + * Inherit this class to create instances of `HybridHaptikaHealthKitSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridHaptikaHealthKit: public HybridHaptikaHealthKitSpec { + * public: + * HybridHaptikaHealthKit(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridHaptikaHealthKitSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridHaptikaHealthKitSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridHaptikaHealthKitSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual bool isAvailable() = 0; + virtual std::shared_ptr> requestReadAuthorization() = 0; + virtual std::shared_ptr> fetchContext(double lookbackDays) = 0; + virtual std::shared_ptr>> fetchLatestHeartbeatSeries() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "HaptikaHealthKit"; + }; + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaPPGFrameProcessorSpec.cpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaPPGFrameProcessorSpec.cpp new file mode 100644 index 0000000..60008a5 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaPPGFrameProcessorSpec.cpp @@ -0,0 +1,25 @@ +/// +/// HybridHaptikaPPGFrameProcessorSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#include "HybridHaptikaPPGFrameProcessorSpec.hpp" + +namespace margelo::nitro::haptika::ppg { + + void HybridHaptikaPPGFrameProcessorSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("startMeasurement", &HybridHaptikaPPGFrameProcessorSpec::startMeasurement); + prototype.registerHybridMethod("processFrame", &HybridHaptikaPPGFrameProcessorSpec::processFrame); + prototype.registerHybridMethod("finishMeasurement", &HybridHaptikaPPGFrameProcessorSpec::finishMeasurement); + prototype.registerHybridMethod("cancelMeasurement", &HybridHaptikaPPGFrameProcessorSpec::cancelMeasurement); + prototype.registerHybridMethod("getThermalState", &HybridHaptikaPPGFrameProcessorSpec::getThermalState); + }); + } + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaPPGFrameProcessorSpec.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaPPGFrameProcessorSpec.hpp new file mode 100644 index 0000000..a0ad447 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/HybridHaptikaPPGFrameProcessorSpec.hpp @@ -0,0 +1,81 @@ +/// +/// HybridHaptikaPPGFrameProcessorSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `NativePPGConfiguration` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePPGConfiguration; } +// Forward declaration of `PPGFrameUpdate` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct PPGFrameUpdate; } +// Forward declaration of `HybridFrameSpec` to properly resolve imports. +namespace margelo::nitro::camera { class HybridFrameSpec; } +// Forward declaration of `NativePPGProcessingResult` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePPGProcessingResult; } +// Forward declaration of `ThermalState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class ThermalState; } + +#include "NativePPGConfiguration.hpp" +#include "PPGFrameUpdate.hpp" +#include +#include +#include +#include "NativePPGProcessingResult.hpp" +#include "ThermalState.hpp" + +namespace margelo::nitro::haptika::ppg { + + using namespace margelo::nitro; + + /** + * An abstract base class for `HaptikaPPGFrameProcessor` + * Inherit this class to create instances of `HybridHaptikaPPGFrameProcessorSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridHaptikaPPGFrameProcessor: public HybridHaptikaPPGFrameProcessorSpec { + * public: + * HybridHaptikaPPGFrameProcessor(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridHaptikaPPGFrameProcessorSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridHaptikaPPGFrameProcessorSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridHaptikaPPGFrameProcessorSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual void startMeasurement(const NativePPGConfiguration& configuration) = 0; + virtual std::optional processFrame(const std::shared_ptr& frame) = 0; + virtual NativePPGProcessingResult finishMeasurement() = 0; + virtual void cancelMeasurement() = 0; + virtual ThermalState getThermalState() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "HaptikaPPGFrameProcessor"; + }; + +} // namespace margelo::nitro::haptika::ppg diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeAcceptedBeat.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeAcceptedBeat.hpp new file mode 100644 index 0000000..22497df --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeAcceptedBeat.hpp @@ -0,0 +1,95 @@ +/// +/// NativeAcceptedBeat.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + + + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeAcceptedBeat). + */ + struct NativeAcceptedBeat final { + public: + double sequence SWIFT_PRIVATE; + double timestampSeconds SWIFT_PRIVATE; + double intervalMs SWIFT_PRIVATE; + double confidence SWIFT_PRIVATE; + + public: + NativeAcceptedBeat() = default; + explicit NativeAcceptedBeat(double sequence, double timestampSeconds, double intervalMs, double confidence): sequence(sequence), timestampSeconds(timestampSeconds), intervalMs(intervalMs), confidence(confidence) {} + + public: + friend bool operator==(const NativeAcceptedBeat& lhs, const NativeAcceptedBeat& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeAcceptedBeat <> JS NativeAcceptedBeat (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeAcceptedBeat fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeAcceptedBeat( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sequence"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "intervalMs"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "confidence"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeAcceptedBeat& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "sequence"), JSIConverter::toJSI(runtime, arg.sequence)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds"), JSIConverter::toJSI(runtime, arg.timestampSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "intervalMs"), JSIConverter::toJSI(runtime, arg.intervalMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "confidence"), JSIConverter::toJSI(runtime, arg.confidence)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sequence")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "intervalMs")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "confidence")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeBPMEstimate.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeBPMEstimate.hpp new file mode 100644 index 0000000..3f705fd --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeBPMEstimate.hpp @@ -0,0 +1,112 @@ +/// +/// NativeBPMEstimate.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeBPMEstimate). + */ + struct NativeBPMEstimate final { + public: + std::optional bpm SWIFT_PRIVATE; + std::optional temporalBpm SWIFT_PRIVATE; + std::optional frequencyBpm SWIFT_PRIVATE; + std::optional methodAgreementBpm SWIFT_PRIVATE; + double validPeakCount SWIFT_PRIVATE; + double rejectedPeakCount SWIFT_PRIVATE; + double confidence SWIFT_PRIVATE; + std::string selectedChannel SWIFT_PRIVATE; + + public: + NativeBPMEstimate() = default; + explicit NativeBPMEstimate(std::optional bpm, std::optional temporalBpm, std::optional frequencyBpm, std::optional methodAgreementBpm, double validPeakCount, double rejectedPeakCount, double confidence, std::string selectedChannel): bpm(bpm), temporalBpm(temporalBpm), frequencyBpm(frequencyBpm), methodAgreementBpm(methodAgreementBpm), validPeakCount(validPeakCount), rejectedPeakCount(rejectedPeakCount), confidence(confidence), selectedChannel(selectedChannel) {} + + public: + friend bool operator==(const NativeBPMEstimate& lhs, const NativeBPMEstimate& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeBPMEstimate <> JS NativeBPMEstimate (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeBPMEstimate fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeBPMEstimate( + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "bpm"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "temporalBpm"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "frequencyBpm"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "methodAgreementBpm"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "validPeakCount"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedPeakCount"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "confidence"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "selectedChannel"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeBPMEstimate& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "bpm"), JSIConverter>::toJSI(runtime, arg.bpm)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "temporalBpm"), JSIConverter>::toJSI(runtime, arg.temporalBpm)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "frequencyBpm"), JSIConverter>::toJSI(runtime, arg.frequencyBpm)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "methodAgreementBpm"), JSIConverter>::toJSI(runtime, arg.methodAgreementBpm)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "validPeakCount"), JSIConverter::toJSI(runtime, arg.validPeakCount)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rejectedPeakCount"), JSIConverter::toJSI(runtime, arg.rejectedPeakCount)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "confidence"), JSIConverter::toJSI(runtime, arg.confidence)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "selectedChannel"), JSIConverter::toJSI(runtime, arg.selectedChannel)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "bpm")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "temporalBpm")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "frequencyBpm")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "methodAgreementBpm")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "validPeakCount")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedPeakCount")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "confidence")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "selectedChannel")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeCardiacMirrorHeartbeat.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeCardiacMirrorHeartbeat.hpp new file mode 100644 index 0000000..59c1f5e --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeCardiacMirrorHeartbeat.hpp @@ -0,0 +1,87 @@ +/// +/// NativeCardiacMirrorHeartbeat.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + + + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeCardiacMirrorHeartbeat). + */ + struct NativeCardiacMirrorHeartbeat final { + public: + double timestampSeconds SWIFT_PRIVATE; + double bpm SWIFT_PRIVATE; + + public: + NativeCardiacMirrorHeartbeat() = default; + explicit NativeCardiacMirrorHeartbeat(double timestampSeconds, double bpm): timestampSeconds(timestampSeconds), bpm(bpm) {} + + public: + friend bool operator==(const NativeCardiacMirrorHeartbeat& lhs, const NativeCardiacMirrorHeartbeat& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeCardiacMirrorHeartbeat <> JS NativeCardiacMirrorHeartbeat (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeCardiacMirrorHeartbeat fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeCardiacMirrorHeartbeat( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "bpm"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeCardiacMirrorHeartbeat& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds"), JSIConverter::toJSI(runtime, arg.timestampSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "bpm"), JSIConverter::toJSI(runtime, arg.bpm)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "bpm")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeCardiacMirrorPatternInfo.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeCardiacMirrorPatternInfo.hpp new file mode 100644 index 0000000..3970ec6 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeCardiacMirrorPatternInfo.hpp @@ -0,0 +1,91 @@ +/// +/// NativeCardiacMirrorPatternInfo.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeCardiacMirrorPatternInfo). + */ + struct NativeCardiacMirrorPatternInfo final { + public: + std::string id SWIFT_PRIVATE; + std::string version SWIFT_PRIVATE; + double latencyBudgetMs SWIFT_PRIVATE; + + public: + NativeCardiacMirrorPatternInfo() = default; + explicit NativeCardiacMirrorPatternInfo(std::string id, std::string version, double latencyBudgetMs): id(id), version(version), latencyBudgetMs(latencyBudgetMs) {} + + public: + friend bool operator==(const NativeCardiacMirrorPatternInfo& lhs, const NativeCardiacMirrorPatternInfo& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeCardiacMirrorPatternInfo <> JS NativeCardiacMirrorPatternInfo (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeCardiacMirrorPatternInfo fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeCardiacMirrorPatternInfo( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "id"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "version"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "latencyBudgetMs"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeCardiacMirrorPatternInfo& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "id"), JSIConverter::toJSI(runtime, arg.id)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "version"), JSIConverter::toJSI(runtime, arg.version)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "latencyBudgetMs"), JSIConverter::toJSI(runtime, arg.latencyBudgetMs)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "id")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "version")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "latencyBudgetMs")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHealthKitContext.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHealthKitContext.hpp new file mode 100644 index 0000000..9c5bfe6 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHealthKitContext.hpp @@ -0,0 +1,111 @@ +/// +/// NativeHealthKitContext.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `NativeHealthMetricSample` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeHealthMetricSample; } + +#include "NativeHealthMetricSample.hpp" +#include +#include +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeHealthKitContext). + */ + struct NativeHealthKitContext final { + public: + bool available SWIFT_PRIVATE; + bool authorized SWIFT_PRIVATE; + std::optional latestRestingHeartRate SWIFT_PRIVATE; + std::optional latestHrvSdnn SWIFT_PRIVATE; + std::vector recentHeartRateSamples SWIFT_PRIVATE; + double contextConfidence SWIFT_PRIVATE; + std::string fetchedAt SWIFT_PRIVATE; + + public: + NativeHealthKitContext() = default; + explicit NativeHealthKitContext(bool available, bool authorized, std::optional latestRestingHeartRate, std::optional latestHrvSdnn, std::vector recentHeartRateSamples, double contextConfidence, std::string fetchedAt): available(available), authorized(authorized), latestRestingHeartRate(latestRestingHeartRate), latestHrvSdnn(latestHrvSdnn), recentHeartRateSamples(recentHeartRateSamples), contextConfidence(contextConfidence), fetchedAt(fetchedAt) {} + + public: + friend bool operator==(const NativeHealthKitContext& lhs, const NativeHealthKitContext& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeHealthKitContext <> JS NativeHealthKitContext (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeHealthKitContext fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeHealthKitContext( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "available"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "authorized"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "latestRestingHeartRate"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "latestHrvSdnn"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "recentHeartRateSamples"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "contextConfidence"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "fetchedAt"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeHealthKitContext& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "available"), JSIConverter::toJSI(runtime, arg.available)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "authorized"), JSIConverter::toJSI(runtime, arg.authorized)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "latestRestingHeartRate"), JSIConverter>::toJSI(runtime, arg.latestRestingHeartRate)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "latestHrvSdnn"), JSIConverter>::toJSI(runtime, arg.latestHrvSdnn)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "recentHeartRateSamples"), JSIConverter>::toJSI(runtime, arg.recentHeartRateSamples)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "contextConfidence"), JSIConverter::toJSI(runtime, arg.contextConfidence)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "fetchedAt"), JSIConverter::toJSI(runtime, arg.fetchedAt)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "available")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "authorized")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "latestRestingHeartRate")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "latestHrvSdnn")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "recentHeartRateSamples")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "contextConfidence")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "fetchedAt")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHealthMetricSample.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHealthMetricSample.hpp new file mode 100644 index 0000000..109ebbe --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHealthMetricSample.hpp @@ -0,0 +1,114 @@ +/// +/// NativeHealthMetricSample.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `HealthMetricType` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class HealthMetricType; } + +#include "HealthMetricType.hpp" +#include +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeHealthMetricSample). + */ + struct NativeHealthMetricSample final { + public: + HealthMetricType type SWIFT_PRIVATE; + double value SWIFT_PRIVATE; + std::string unit SWIFT_PRIVATE; + std::string startDate SWIFT_PRIVATE; + std::string endDate SWIFT_PRIVATE; + std::optional sourceName SWIFT_PRIVATE; + std::optional sourceBundleIdentifier SWIFT_PRIVATE; + std::optional deviceName SWIFT_PRIVATE; + + public: + NativeHealthMetricSample() = default; + explicit NativeHealthMetricSample(HealthMetricType type, double value, std::string unit, std::string startDate, std::string endDate, std::optional sourceName, std::optional sourceBundleIdentifier, std::optional deviceName): type(type), value(value), unit(unit), startDate(startDate), endDate(endDate), sourceName(sourceName), sourceBundleIdentifier(sourceBundleIdentifier), deviceName(deviceName) {} + + public: + friend bool operator==(const NativeHealthMetricSample& lhs, const NativeHealthMetricSample& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeHealthMetricSample <> JS NativeHealthMetricSample (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeHealthMetricSample fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeHealthMetricSample( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "type"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "value"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "unit"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startDate"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endDate"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceName"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceBundleIdentifier"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "deviceName"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeHealthMetricSample& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "type"), JSIConverter::toJSI(runtime, arg.type)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "value"), JSIConverter::toJSI(runtime, arg.value)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "unit"), JSIConverter::toJSI(runtime, arg.unit)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "startDate"), JSIConverter::toJSI(runtime, arg.startDate)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "endDate"), JSIConverter::toJSI(runtime, arg.endDate)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "sourceName"), JSIConverter>::toJSI(runtime, arg.sourceName)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "sourceBundleIdentifier"), JSIConverter>::toJSI(runtime, arg.sourceBundleIdentifier)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "deviceName"), JSIConverter>::toJSI(runtime, arg.deviceName)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "type")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "value")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "unit")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startDate")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endDate")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceName")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceBundleIdentifier")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "deviceName")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHeartbeatSeriesSummary.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHeartbeatSeriesSummary.hpp new file mode 100644 index 0000000..d2d9494 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeHeartbeatSeriesSummary.hpp @@ -0,0 +1,105 @@ +/// +/// NativeHeartbeatSeriesSummary.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeHeartbeatSeriesSummary). + */ + struct NativeHeartbeatSeriesSummary final { + public: + std::string startDate SWIFT_PRIVATE; + std::string endDate SWIFT_PRIVATE; + std::optional sourceName SWIFT_PRIVATE; + std::optional sourceBundleIdentifier SWIFT_PRIVATE; + std::vector heartbeatTimesSinceSeriesStartSeconds SWIFT_PRIVATE; + std::vector precededByGapIndexes SWIFT_PRIVATE; + + public: + NativeHeartbeatSeriesSummary() = default; + explicit NativeHeartbeatSeriesSummary(std::string startDate, std::string endDate, std::optional sourceName, std::optional sourceBundleIdentifier, std::vector heartbeatTimesSinceSeriesStartSeconds, std::vector precededByGapIndexes): startDate(startDate), endDate(endDate), sourceName(sourceName), sourceBundleIdentifier(sourceBundleIdentifier), heartbeatTimesSinceSeriesStartSeconds(heartbeatTimesSinceSeriesStartSeconds), precededByGapIndexes(precededByGapIndexes) {} + + public: + friend bool operator==(const NativeHeartbeatSeriesSummary& lhs, const NativeHeartbeatSeriesSummary& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeHeartbeatSeriesSummary <> JS NativeHeartbeatSeriesSummary (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeHeartbeatSeriesSummary fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeHeartbeatSeriesSummary( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startDate"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endDate"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceName"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceBundleIdentifier"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "heartbeatTimesSinceSeriesStartSeconds"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "precededByGapIndexes"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeHeartbeatSeriesSummary& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "startDate"), JSIConverter::toJSI(runtime, arg.startDate)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "endDate"), JSIConverter::toJSI(runtime, arg.endDate)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "sourceName"), JSIConverter>::toJSI(runtime, arg.sourceName)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "sourceBundleIdentifier"), JSIConverter>::toJSI(runtime, arg.sourceBundleIdentifier)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "heartbeatTimesSinceSeriesStartSeconds"), JSIConverter>::toJSI(runtime, arg.heartbeatTimesSinceSeriesStartSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "precededByGapIndexes"), JSIConverter>::toJSI(runtime, arg.precededByGapIndexes)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startDate")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endDate")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceName")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sourceBundleIdentifier")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "heartbeatTimesSinceSeriesStartSeconds")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "precededByGapIndexes")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePPGConfiguration.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePPGConfiguration.hpp new file mode 100644 index 0000000..57a370b --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePPGConfiguration.hpp @@ -0,0 +1,112 @@ +/// +/// NativePPGConfiguration.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `PPGSessionPurpose` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class PPGSessionPurpose; } + +#include "PPGSessionPurpose.hpp" + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativePPGConfiguration). + */ + struct NativePPGConfiguration final { + public: + PPGSessionPurpose sessionPurpose SWIFT_PRIVATE; + double targetSampleRate SWIFT_PRIVATE; + double minimumFrequencyHz SWIFT_PRIVATE; + double maximumFrequencyHz SWIFT_PRIVATE; + double stabilizationSeconds SWIFT_PRIVATE; + double measurementSeconds SWIFT_PRIVATE; + double emitEveryFrames SWIFT_PRIVATE; + double minimumContactConfidence SWIFT_PRIVATE; + + public: + NativePPGConfiguration() = default; + explicit NativePPGConfiguration(PPGSessionPurpose sessionPurpose, double targetSampleRate, double minimumFrequencyHz, double maximumFrequencyHz, double stabilizationSeconds, double measurementSeconds, double emitEveryFrames, double minimumContactConfidence): sessionPurpose(sessionPurpose), targetSampleRate(targetSampleRate), minimumFrequencyHz(minimumFrequencyHz), maximumFrequencyHz(maximumFrequencyHz), stabilizationSeconds(stabilizationSeconds), measurementSeconds(measurementSeconds), emitEveryFrames(emitEveryFrames), minimumContactConfidence(minimumContactConfidence) {} + + public: + friend bool operator==(const NativePPGConfiguration& lhs, const NativePPGConfiguration& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativePPGConfiguration <> JS NativePPGConfiguration (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativePPGConfiguration fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativePPGConfiguration( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sessionPurpose"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "targetSampleRate"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "minimumFrequencyHz"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "maximumFrequencyHz"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "stabilizationSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "measurementSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "emitEveryFrames"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "minimumContactConfidence"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativePPGConfiguration& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "sessionPurpose"), JSIConverter::toJSI(runtime, arg.sessionPurpose)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "targetSampleRate"), JSIConverter::toJSI(runtime, arg.targetSampleRate)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "minimumFrequencyHz"), JSIConverter::toJSI(runtime, arg.minimumFrequencyHz)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "maximumFrequencyHz"), JSIConverter::toJSI(runtime, arg.maximumFrequencyHz)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "stabilizationSeconds"), JSIConverter::toJSI(runtime, arg.stabilizationSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "measurementSeconds"), JSIConverter::toJSI(runtime, arg.measurementSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "emitEveryFrames"), JSIConverter::toJSI(runtime, arg.emitEveryFrames)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "minimumContactConfidence"), JSIConverter::toJSI(runtime, arg.minimumContactConfidence)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "sessionPurpose")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "targetSampleRate")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "minimumFrequencyHz")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "maximumFrequencyHz")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "stabilizationSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "measurementSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "emitEveryFrames")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "minimumContactConfidence")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePPGProcessingResult.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePPGProcessingResult.hpp new file mode 100644 index 0000000..2dae699 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePPGProcessingResult.hpp @@ -0,0 +1,124 @@ +/// +/// NativePPGProcessingResult.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `NativeBPMEstimate` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeBPMEstimate; } +// Forward declaration of `NativePRVMetrics` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativePRVMetrics; } +// Forward declaration of `NativeSignalQualityResult` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeSignalQualityResult; } + +#include +#include +#include "NativeBPMEstimate.hpp" +#include "NativePRVMetrics.hpp" +#include "NativeSignalQualityResult.hpp" + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativePPGProcessingResult). + */ + struct NativePPGProcessingResult final { + public: + bool valid SWIFT_PRIVATE; + std::optional invalidReason SWIFT_PRIVATE; + NativeBPMEstimate bpm SWIFT_PRIVATE; + NativePRVMetrics prv SWIFT_PRIVATE; + NativeSignalQualityResult quality SWIFT_PRIVATE; + double startedAtMonotonicSeconds SWIFT_PRIVATE; + double endedAtMonotonicSeconds SWIFT_PRIVATE; + double rawSampleCount SWIFT_PRIVATE; + double resampledSampleCount SWIFT_PRIVATE; + + public: + NativePPGProcessingResult() = default; + explicit NativePPGProcessingResult(bool valid, std::optional invalidReason, NativeBPMEstimate bpm, NativePRVMetrics prv, NativeSignalQualityResult quality, double startedAtMonotonicSeconds, double endedAtMonotonicSeconds, double rawSampleCount, double resampledSampleCount): valid(valid), invalidReason(invalidReason), bpm(bpm), prv(prv), quality(quality), startedAtMonotonicSeconds(startedAtMonotonicSeconds), endedAtMonotonicSeconds(endedAtMonotonicSeconds), rawSampleCount(rawSampleCount), resampledSampleCount(resampledSampleCount) {} + + public: + friend bool operator==(const NativePPGProcessingResult& lhs, const NativePPGProcessingResult& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativePPGProcessingResult <> JS NativePPGProcessingResult (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativePPGProcessingResult fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativePPGProcessingResult( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "valid"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "invalidReason"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "bpm"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "prv"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "quality"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startedAtMonotonicSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endedAtMonotonicSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rawSampleCount"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "resampledSampleCount"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativePPGProcessingResult& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "valid"), JSIConverter::toJSI(runtime, arg.valid)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "invalidReason"), JSIConverter>::toJSI(runtime, arg.invalidReason)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "bpm"), JSIConverter::toJSI(runtime, arg.bpm)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "prv"), JSIConverter::toJSI(runtime, arg.prv)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "quality"), JSIConverter::toJSI(runtime, arg.quality)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "startedAtMonotonicSeconds"), JSIConverter::toJSI(runtime, arg.startedAtMonotonicSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "endedAtMonotonicSeconds"), JSIConverter::toJSI(runtime, arg.endedAtMonotonicSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rawSampleCount"), JSIConverter::toJSI(runtime, arg.rawSampleCount)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "resampledSampleCount"), JSIConverter::toJSI(runtime, arg.resampledSampleCount)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "valid")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "invalidReason")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "bpm")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "prv")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "quality")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startedAtMonotonicSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "endedAtMonotonicSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rawSampleCount")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "resampledSampleCount")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePRVMetrics.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePRVMetrics.hpp new file mode 100644 index 0000000..7b5db06 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativePRVMetrics.hpp @@ -0,0 +1,131 @@ +/// +/// NativePRVMetrics.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativePRVMetrics). + */ + struct NativePRVMetrics final { + public: + std::optional estimatedRmssdMs SWIFT_PRIVATE; + std::optional estimatedSdnnMs SWIFT_PRIVATE; + std::optional meanIpiMs SWIFT_PRIVATE; + std::optional medianIpiMs SWIFT_PRIVATE; + std::optional pnn50Percent SWIFT_PRIVATE; + std::optional rangeIpiMs SWIFT_PRIVATE; + std::optional modeIpiMs SWIFT_PRIVATE; + std::optional amo50Percent SWIFT_PRIVATE; + std::optional coefficientOfVariation SWIFT_PRIVATE; + double validIntervalCount SWIFT_PRIVATE; + double rejectedIntervalCount SWIFT_PRIVATE; + double rejectedIntervalRatio SWIFT_PRIVATE; + bool reliable SWIFT_PRIVATE; + + public: + NativePRVMetrics() = default; + explicit NativePRVMetrics(std::optional estimatedRmssdMs, std::optional estimatedSdnnMs, std::optional meanIpiMs, std::optional medianIpiMs, std::optional pnn50Percent, std::optional rangeIpiMs, std::optional modeIpiMs, std::optional amo50Percent, std::optional coefficientOfVariation, double validIntervalCount, double rejectedIntervalCount, double rejectedIntervalRatio, bool reliable): estimatedRmssdMs(estimatedRmssdMs), estimatedSdnnMs(estimatedSdnnMs), meanIpiMs(meanIpiMs), medianIpiMs(medianIpiMs), pnn50Percent(pnn50Percent), rangeIpiMs(rangeIpiMs), modeIpiMs(modeIpiMs), amo50Percent(amo50Percent), coefficientOfVariation(coefficientOfVariation), validIntervalCount(validIntervalCount), rejectedIntervalCount(rejectedIntervalCount), rejectedIntervalRatio(rejectedIntervalRatio), reliable(reliable) {} + + public: + friend bool operator==(const NativePRVMetrics& lhs, const NativePRVMetrics& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativePRVMetrics <> JS NativePRVMetrics (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativePRVMetrics fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativePRVMetrics( + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "estimatedRmssdMs"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "estimatedSdnnMs"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "meanIpiMs"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "medianIpiMs"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "pnn50Percent"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rangeIpiMs"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "modeIpiMs"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "amo50Percent"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "coefficientOfVariation"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "validIntervalCount"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalCount"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalRatio"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "reliable"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativePRVMetrics& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "estimatedRmssdMs"), JSIConverter>::toJSI(runtime, arg.estimatedRmssdMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "estimatedSdnnMs"), JSIConverter>::toJSI(runtime, arg.estimatedSdnnMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "meanIpiMs"), JSIConverter>::toJSI(runtime, arg.meanIpiMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "medianIpiMs"), JSIConverter>::toJSI(runtime, arg.medianIpiMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "pnn50Percent"), JSIConverter>::toJSI(runtime, arg.pnn50Percent)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rangeIpiMs"), JSIConverter>::toJSI(runtime, arg.rangeIpiMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "modeIpiMs"), JSIConverter>::toJSI(runtime, arg.modeIpiMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "amo50Percent"), JSIConverter>::toJSI(runtime, arg.amo50Percent)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "coefficientOfVariation"), JSIConverter>::toJSI(runtime, arg.coefficientOfVariation)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "validIntervalCount"), JSIConverter::toJSI(runtime, arg.validIntervalCount)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalCount"), JSIConverter::toJSI(runtime, arg.rejectedIntervalCount)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalRatio"), JSIConverter::toJSI(runtime, arg.rejectedIntervalRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "reliable"), JSIConverter::toJSI(runtime, arg.reliable)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "estimatedRmssdMs")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "estimatedSdnnMs")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "meanIpiMs")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "medianIpiMs")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "pnn50Percent")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rangeIpiMs")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "modeIpiMs")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "amo50Percent")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "coefficientOfVariation")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "validIntervalCount")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalCount")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalRatio")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "reliable")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeSignalQualityResult.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeSignalQualityResult.hpp new file mode 100644 index 0000000..2a86f42 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/NativeSignalQualityResult.hpp @@ -0,0 +1,129 @@ +/// +/// NativeSignalQualityResult.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + +#include +#include +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (NativeSignalQualityResult). + */ + struct NativeSignalQualityResult final { + public: + double score SWIFT_PRIVATE; + std::string level SWIFT_PRIVATE; + double usableDurationSeconds SWIFT_PRIVATE; + double droppedFrameRatio SWIFT_PRIVATE; + double saturationRatio SWIFT_PRIVATE; + double rejectedIntervalRatio SWIFT_PRIVATE; + std::optional signalToNoiseRatio SWIFT_PRIVATE; + double motionArtifactRatio SWIFT_PRIVATE; + double methodAgreementScore SWIFT_PRIVATE; + double timestampJitterRatio SWIFT_PRIVATE; + double longestGapSeconds SWIFT_PRIVATE; + std::vector issues SWIFT_PRIVATE; + + public: + NativeSignalQualityResult() = default; + explicit NativeSignalQualityResult(double score, std::string level, double usableDurationSeconds, double droppedFrameRatio, double saturationRatio, double rejectedIntervalRatio, std::optional signalToNoiseRatio, double motionArtifactRatio, double methodAgreementScore, double timestampJitterRatio, double longestGapSeconds, std::vector issues): score(score), level(level), usableDurationSeconds(usableDurationSeconds), droppedFrameRatio(droppedFrameRatio), saturationRatio(saturationRatio), rejectedIntervalRatio(rejectedIntervalRatio), signalToNoiseRatio(signalToNoiseRatio), motionArtifactRatio(motionArtifactRatio), methodAgreementScore(methodAgreementScore), timestampJitterRatio(timestampJitterRatio), longestGapSeconds(longestGapSeconds), issues(issues) {} + + public: + friend bool operator==(const NativeSignalQualityResult& lhs, const NativeSignalQualityResult& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ NativeSignalQualityResult <> JS NativeSignalQualityResult (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::NativeSignalQualityResult fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::NativeSignalQualityResult( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "score"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "level"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "usableDurationSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "droppedFrameRatio"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "saturationRatio"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalRatio"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "signalToNoiseRatio"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "motionArtifactRatio"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "methodAgreementScore"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampJitterRatio"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "longestGapSeconds"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "issues"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::NativeSignalQualityResult& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "score"), JSIConverter::toJSI(runtime, arg.score)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "level"), JSIConverter::toJSI(runtime, arg.level)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "usableDurationSeconds"), JSIConverter::toJSI(runtime, arg.usableDurationSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "droppedFrameRatio"), JSIConverter::toJSI(runtime, arg.droppedFrameRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "saturationRatio"), JSIConverter::toJSI(runtime, arg.saturationRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalRatio"), JSIConverter::toJSI(runtime, arg.rejectedIntervalRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "signalToNoiseRatio"), JSIConverter>::toJSI(runtime, arg.signalToNoiseRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "motionArtifactRatio"), JSIConverter::toJSI(runtime, arg.motionArtifactRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "methodAgreementScore"), JSIConverter::toJSI(runtime, arg.methodAgreementScore)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "timestampJitterRatio"), JSIConverter::toJSI(runtime, arg.timestampJitterRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "longestGapSeconds"), JSIConverter::toJSI(runtime, arg.longestGapSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "issues"), JSIConverter>::toJSI(runtime, arg.issues)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "score")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "level")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "usableDurationSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "droppedFrameRatio")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "saturationRatio")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "rejectedIntervalRatio")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "signalToNoiseRatio")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "motionArtifactRatio")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "methodAgreementScore")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampJitterRatio")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "longestGapSeconds")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "issues")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/PPGFrameUpdate.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/PPGFrameUpdate.hpp new file mode 100644 index 0000000..c68ea07 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/PPGFrameUpdate.hpp @@ -0,0 +1,152 @@ +/// +/// PPGFrameUpdate.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `FingerContactState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class FingerContactState; } +// Forward declaration of `ThermalState` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { enum class ThermalState; } +// Forward declaration of `NativeAcceptedBeat` to properly resolve imports. +namespace margelo::nitro::haptika::ppg { struct NativeAcceptedBeat; } + +#include "FingerContactState.hpp" +#include "ThermalState.hpp" +#include +#include "NativeAcceptedBeat.hpp" +#include + +namespace margelo::nitro::haptika::ppg { + + /** + * A struct which can be represented as a JavaScript object (PPGFrameUpdate). + */ + struct PPGFrameUpdate final { + public: + double timestampSeconds SWIFT_PRIVATE; + FingerContactState contactState SWIFT_PRIVATE; + double contactConfidence SWIFT_PRIVATE; + double signalLevel SWIFT_PRIVATE; + double saturationRatio SWIFT_PRIVATE; + double motionArtifactScore SWIFT_PRIVATE; + double collectedSampleCount SWIFT_PRIVATE; + double usableDurationSeconds SWIFT_PRIVATE; + double droppedFrameCount SWIFT_PRIVATE; + ThermalState thermalState SWIFT_PRIVATE; + double waveformValue SWIFT_PRIVATE; + std::optional liveBpm SWIFT_PRIVATE; + std::optional liveIpiMs SWIFT_PRIVATE; + double beatSequence SWIFT_PRIVATE; + std::vector acceptedBeats SWIFT_PRIVATE; + std::optional liveWindowQuality SWIFT_PRIVATE; + + public: + PPGFrameUpdate() = default; + explicit PPGFrameUpdate(double timestampSeconds, FingerContactState contactState, double contactConfidence, double signalLevel, double saturationRatio, double motionArtifactScore, double collectedSampleCount, double usableDurationSeconds, double droppedFrameCount, ThermalState thermalState, double waveformValue, std::optional liveBpm, std::optional liveIpiMs, double beatSequence, std::vector acceptedBeats, std::optional liveWindowQuality): timestampSeconds(timestampSeconds), contactState(contactState), contactConfidence(contactConfidence), signalLevel(signalLevel), saturationRatio(saturationRatio), motionArtifactScore(motionArtifactScore), collectedSampleCount(collectedSampleCount), usableDurationSeconds(usableDurationSeconds), droppedFrameCount(droppedFrameCount), thermalState(thermalState), waveformValue(waveformValue), liveBpm(liveBpm), liveIpiMs(liveIpiMs), beatSequence(beatSequence), acceptedBeats(acceptedBeats), liveWindowQuality(liveWindowQuality) {} + + public: + friend bool operator==(const PPGFrameUpdate& lhs, const PPGFrameUpdate& rhs) = default; + }; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ PPGFrameUpdate <> JS PPGFrameUpdate (object) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::PPGFrameUpdate fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + jsi::Object obj = arg.asObject(runtime); + return margelo::nitro::haptika::ppg::PPGFrameUpdate( + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "contactState"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "contactConfidence"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "signalLevel"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "saturationRatio"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "motionArtifactScore"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "collectedSampleCount"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "usableDurationSeconds"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "droppedFrameCount"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "thermalState"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "waveformValue"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "liveBpm"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "liveIpiMs"))), + JSIConverter::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "beatSequence"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "acceptedBeats"))), + JSIConverter>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "liveWindowQuality"))) + ); + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::haptika::ppg::PPGFrameUpdate& arg) { + jsi::Object obj(runtime); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds"), JSIConverter::toJSI(runtime, arg.timestampSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "contactState"), JSIConverter::toJSI(runtime, arg.contactState)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "contactConfidence"), JSIConverter::toJSI(runtime, arg.contactConfidence)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "signalLevel"), JSIConverter::toJSI(runtime, arg.signalLevel)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "saturationRatio"), JSIConverter::toJSI(runtime, arg.saturationRatio)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "motionArtifactScore"), JSIConverter::toJSI(runtime, arg.motionArtifactScore)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "collectedSampleCount"), JSIConverter::toJSI(runtime, arg.collectedSampleCount)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "usableDurationSeconds"), JSIConverter::toJSI(runtime, arg.usableDurationSeconds)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "droppedFrameCount"), JSIConverter::toJSI(runtime, arg.droppedFrameCount)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "thermalState"), JSIConverter::toJSI(runtime, arg.thermalState)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "waveformValue"), JSIConverter::toJSI(runtime, arg.waveformValue)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "liveBpm"), JSIConverter>::toJSI(runtime, arg.liveBpm)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "liveIpiMs"), JSIConverter>::toJSI(runtime, arg.liveIpiMs)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "beatSequence"), JSIConverter::toJSI(runtime, arg.beatSequence)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "acceptedBeats"), JSIConverter>::toJSI(runtime, arg.acceptedBeats)); + obj.setProperty(runtime, PropNameIDCache::get(runtime, "liveWindowQuality"), JSIConverter>::toJSI(runtime, arg.liveWindowQuality)); + return obj; + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isObject()) { + return false; + } + jsi::Object obj = value.getObject(runtime); + if (!nitro::isPlainObject(runtime, obj)) { + return false; + } + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "timestampSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "contactState")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "contactConfidence")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "signalLevel")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "saturationRatio")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "motionArtifactScore")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "collectedSampleCount")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "usableDurationSeconds")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "droppedFrameCount")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "thermalState")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "waveformValue")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "liveBpm")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "liveIpiMs")))) return false; + if (!JSIConverter::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "beatSequence")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "acceptedBeats")))) return false; + if (!JSIConverter>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "liveWindowQuality")))) return false; + return true; + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/PPGSessionPurpose.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/PPGSessionPurpose.hpp new file mode 100644 index 0000000..bd3bad4 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/PPGSessionPurpose.hpp @@ -0,0 +1,76 @@ +/// +/// PPGSessionPurpose.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +namespace margelo::nitro::haptika::ppg { + + /** + * An enum which can be represented as a JavaScript union (PPGSessionPurpose). + */ + enum class PPGSessionPurpose { + SPOTMEASUREMENT SWIFT_NAME(spotmeasurement) = 0, + COHERENCE SWIFT_NAME(coherence) = 1, + } CLOSED_ENUM; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ PPGSessionPurpose <> JS PPGSessionPurpose (union) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::PPGSessionPurpose fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + std::string unionValue = JSIConverter::fromJSI(runtime, arg); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("spotMeasurement"): return margelo::nitro::haptika::ppg::PPGSessionPurpose::SPOTMEASUREMENT; + case hashString("coherence"): return margelo::nitro::haptika::ppg::PPGSessionPurpose::COHERENCE; + default: [[unlikely]] + throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum PPGSessionPurpose - invalid value!"); + } + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::haptika::ppg::PPGSessionPurpose arg) { + switch (arg) { + case margelo::nitro::haptika::ppg::PPGSessionPurpose::SPOTMEASUREMENT: return JSIConverter::toJSI(runtime, "spotMeasurement"); + case margelo::nitro::haptika::ppg::PPGSessionPurpose::COHERENCE: return JSIConverter::toJSI(runtime, "coherence"); + default: [[unlikely]] + throw std::invalid_argument("Cannot convert PPGSessionPurpose to JS - invalid value: " + + std::to_string(static_cast(arg)) + "!"); + } + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isString()) { + return false; + } + std::string unionValue = JSIConverter::fromJSI(runtime, value); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("spotMeasurement"): + case hashString("coherence"): + return true; + default: + return false; + } + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/ThermalState.hpp b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/ThermalState.hpp new file mode 100644 index 0000000..f00c087 --- /dev/null +++ b/packages/react-native-haptika-ppg/nitrogen/generated/shared/c++/ThermalState.hpp @@ -0,0 +1,88 @@ +/// +/// ThermalState.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +namespace margelo::nitro::haptika::ppg { + + /** + * An enum which can be represented as a JavaScript union (ThermalState). + */ + enum class ThermalState { + NOMINAL SWIFT_NAME(nominal) = 0, + FAIR SWIFT_NAME(fair) = 1, + SERIOUS SWIFT_NAME(serious) = 2, + CRITICAL SWIFT_NAME(critical) = 3, + UNKNOWN SWIFT_NAME(unknown) = 4, + } CLOSED_ENUM; + +} // namespace margelo::nitro::haptika::ppg + +namespace margelo::nitro { + + // C++ ThermalState <> JS ThermalState (union) + template <> + struct JSIConverter final { + static inline margelo::nitro::haptika::ppg::ThermalState fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + std::string unionValue = JSIConverter::fromJSI(runtime, arg); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("nominal"): return margelo::nitro::haptika::ppg::ThermalState::NOMINAL; + case hashString("fair"): return margelo::nitro::haptika::ppg::ThermalState::FAIR; + case hashString("serious"): return margelo::nitro::haptika::ppg::ThermalState::SERIOUS; + case hashString("critical"): return margelo::nitro::haptika::ppg::ThermalState::CRITICAL; + case hashString("unknown"): return margelo::nitro::haptika::ppg::ThermalState::UNKNOWN; + default: [[unlikely]] + throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum ThermalState - invalid value!"); + } + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::haptika::ppg::ThermalState arg) { + switch (arg) { + case margelo::nitro::haptika::ppg::ThermalState::NOMINAL: return JSIConverter::toJSI(runtime, "nominal"); + case margelo::nitro::haptika::ppg::ThermalState::FAIR: return JSIConverter::toJSI(runtime, "fair"); + case margelo::nitro::haptika::ppg::ThermalState::SERIOUS: return JSIConverter::toJSI(runtime, "serious"); + case margelo::nitro::haptika::ppg::ThermalState::CRITICAL: return JSIConverter::toJSI(runtime, "critical"); + case margelo::nitro::haptika::ppg::ThermalState::UNKNOWN: return JSIConverter::toJSI(runtime, "unknown"); + default: [[unlikely]] + throw std::invalid_argument("Cannot convert ThermalState to JS - invalid value: " + + std::to_string(static_cast(arg)) + "!"); + } + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isString()) { + return false; + } + std::string unionValue = JSIConverter::fromJSI(runtime, value); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("nominal"): + case hashString("fair"): + case hashString("serious"): + case hashString("critical"): + case hashString("unknown"): + return true; + default: + return false; + } + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-haptika-ppg/package.json b/packages/react-native-haptika-ppg/package.json new file mode 100644 index 0000000..fe24083 --- /dev/null +++ b/packages/react-native-haptika-ppg/package.json @@ -0,0 +1,36 @@ +{ + "name": "react-native-haptika-ppg", + "version": "0.1.0", + "description": "iPhone-only native PPG, thermal-state, and read-only HealthKit integration for Haptika", + "main": "src/index.ts", + "types": "src/index.ts", + "react-native": "src/index.ts", + "source": "src/index.ts", + "files": [ + "src", + "ios", + "nitrogen", + "nitro.json", + "react-native.config.js", + "HaptikaPPG.podspec" + ], + "scripts": { + "specs": "nitrogen", + "typecheck": "tsc --noEmit" + }, + "license": "UNLICENSED", + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-nitro-modules": "*", + "react-native-vision-camera": "*" + }, + "devDependencies": { + "nitrogen": "0.36.1", + "react": "19.2.3", + "react-native": "0.86.0", + "react-native-nitro-modules": "0.36.1", + "react-native-vision-camera": "5.1.0", + "typescript": "^5.8.3" + } +} diff --git a/packages/react-native-haptika-ppg/react-native.config.js b/packages/react-native-haptika-ppg/react-native.config.js new file mode 100644 index 0000000..b711c2c --- /dev/null +++ b/packages/react-native-haptika-ppg/react-native.config.js @@ -0,0 +1,8 @@ +module.exports = { + dependency: { + platforms: { + ios: {}, + android: null, + }, + }, +}; diff --git a/packages/react-native-haptika-ppg/src/index.ts b/packages/react-native-haptika-ppg/src/index.ts new file mode 100644 index 0000000..00b9979 --- /dev/null +++ b/packages/react-native-haptika-ppg/src/index.ts @@ -0,0 +1,20 @@ +import {NitroModules} from 'react-native-nitro-modules'; + +import type { + HaptikaHealthKit, + HaptikaPPGFrameProcessor, + HaptikaCardiacMirror, +} from './specs/HaptikaPPG.nitro'; + +export * from './specs/HaptikaPPG.nitro'; + +export const haptikaPPG = + NitroModules.createHybridObject( + 'HaptikaPPGFrameProcessor', + ); + +export const haptikaHealthKit = + NitroModules.createHybridObject('HaptikaHealthKit'); + +export const haptikaCardiacMirror = + NitroModules.createHybridObject('HaptikaCardiacMirror'); diff --git a/packages/react-native-haptika-ppg/src/specs/HaptikaPPG.nitro.ts b/packages/react-native-haptika-ppg/src/specs/HaptikaPPG.nitro.ts new file mode 100644 index 0000000..f5168cc --- /dev/null +++ b/packages/react-native-haptika-ppg/src/specs/HaptikaPPG.nitro.ts @@ -0,0 +1,187 @@ +import type {HybridObject} from 'react-native-nitro-modules'; +import type {Frame} from 'react-native-vision-camera'; + +export type ThermalState = + | 'nominal' + | 'fair' + | 'serious' + | 'critical' + | 'unknown'; + +export type FingerContactState = + | 'absent' + | 'adjusting' + | 'valid' + | 'tooMuchPressure' + | 'overexposed' + | 'unstable'; + +export type PPGSessionPurpose = 'spotMeasurement' | 'coherence'; + +export interface NativePPGConfiguration { + sessionPurpose: PPGSessionPurpose; + targetSampleRate: number; + minimumFrequencyHz: number; + maximumFrequencyHz: number; + stabilizationSeconds: number; + measurementSeconds: number; + emitEveryFrames: number; + minimumContactConfidence: number; +} + +export interface NativeAcceptedBeat { + sequence: number; + timestampSeconds: number; + intervalMs: number; + confidence: number; +} + +export interface PPGFrameUpdate { + timestampSeconds: number; + contactState: FingerContactState; + contactConfidence: number; + signalLevel: number; + saturationRatio: number; + motionArtifactScore: number; + collectedSampleCount: number; + usableDurationSeconds: number; + droppedFrameCount: number; + thermalState: ThermalState; + waveformValue: number; + liveBpm?: number; + liveIpiMs?: number; + beatSequence: number; + acceptedBeats: NativeAcceptedBeat[]; + liveWindowQuality?: number; +} + +export interface NativeBPMEstimate { + bpm?: number; + temporalBpm?: number; + frequencyBpm?: number; + methodAgreementBpm?: number; + validPeakCount: number; + rejectedPeakCount: number; + confidence: number; + selectedChannel: string; +} + +export interface NativePRVMetrics { + estimatedRmssdMs?: number; + estimatedSdnnMs?: number; + meanIpiMs?: number; + medianIpiMs?: number; + pnn50Percent?: number; + rangeIpiMs?: number; + modeIpiMs?: number; + amo50Percent?: number; + coefficientOfVariation?: number; + validIntervalCount: number; + rejectedIntervalCount: number; + rejectedIntervalRatio: number; + reliable: boolean; +} + +export interface NativeSignalQualityResult { + score: number; + level: string; + usableDurationSeconds: number; + droppedFrameRatio: number; + saturationRatio: number; + rejectedIntervalRatio: number; + signalToNoiseRatio?: number; + motionArtifactRatio: number; + methodAgreementScore: number; + timestampJitterRatio: number; + longestGapSeconds: number; + issues: string[]; +} + +export interface NativePPGProcessingResult { + valid: boolean; + invalidReason?: string; + bpm: NativeBPMEstimate; + prv: NativePRVMetrics; + quality: NativeSignalQualityResult; + startedAtMonotonicSeconds: number; + endedAtMonotonicSeconds: number; + rawSampleCount: number; + resampledSampleCount: number; +} + +export interface HaptikaPPGFrameProcessor + extends HybridObject<{ios: 'swift'}> { + startMeasurement(configuration: NativePPGConfiguration): void; + processFrame(frame: Frame): PPGFrameUpdate | undefined; + finishMeasurement(): NativePPGProcessingResult; + cancelMeasurement(): void; + getThermalState(): ThermalState; +} + +export type HealthMetricType = 'heartRate' | 'restingHeartRate' | 'hrvSdnn'; + +export interface NativeHealthMetricSample { + type: HealthMetricType; + value: number; + unit: string; + startDate: string; + endDate: string; + sourceName?: string; + sourceBundleIdentifier?: string; + deviceName?: string; +} + +export interface NativeHealthKitContext { + available: boolean; + authorized: boolean; + latestRestingHeartRate?: NativeHealthMetricSample; + latestHrvSdnn?: NativeHealthMetricSample; + recentHeartRateSamples: NativeHealthMetricSample[]; + contextConfidence: number; + fetchedAt: string; +} + +export interface NativeHeartbeatSeriesSummary { + startDate: string; + endDate: string; + sourceName?: string; + sourceBundleIdentifier?: string; + heartbeatTimesSinceSeriesStartSeconds: number[]; + precededByGapIndexes: number[]; +} + +export interface HaptikaHealthKit extends HybridObject<{ios: 'swift'}> { + isAvailable(): boolean; + requestReadAuthorization(): Promise; + fetchContext(lookbackDays: number): Promise; + fetchLatestHeartbeatSeries(): Promise; +} + +export interface NativeCardiacMirrorPatternInfo { + id: string; + version: string; + latencyBudgetMs: number; +} + +export interface NativeCardiacMirrorHeartbeat { + timestampSeconds: number; + bpm: number; +} + +/** + * Versioned native haptic surface for Module 7 and the single iPhone owner of + * WCSession. Watch heart-rate messages are cadence samples, not biological + * beat events; the UI reconstructs a target rhythm from them. + */ +export interface HaptikaCardiacMirror extends HybridObject<{ios: 'swift'}> { + isSupported(): boolean; + patternInfo(): NativeCardiacMirrorPatternInfo; + start(targetBpm: number): void; + pulse(): void; + stop(): void; + startWatchSession(): void; + watchReachable(): boolean; + latestHeartbeat(): NativeCardiacMirrorHeartbeat | undefined; + playRegulationProtocol(kind: string): number; + stopRegulationProtocol(): void; +} diff --git a/packages/react-native-haptika-ppg/tsconfig.json b/packages/react-native-haptika-ppg/tsconfig.json new file mode 100644 index 0000000..67d9b09 --- /dev/null +++ b/packages/react-native-haptika-ppg/tsconfig.json @@ -0,0 +1,23 @@ +{ + "include": ["src"], + "compilerOptions": { + "composite": true, + "noEmit": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "strict": true, + "target": "esnext", + "verbatimModuleSyntax": true + } +} diff --git a/qa/figma-123/comparison-all.jpg b/qa/figma-123/comparison-all.jpg new file mode 100644 index 0000000..8c08e47 Binary files /dev/null and b/qa/figma-123/comparison-all.jpg differ diff --git a/qa/figma-123/implementation-home.jpg b/qa/figma-123/implementation-home.jpg new file mode 100644 index 0000000..4c209d9 Binary files /dev/null and b/qa/figma-123/implementation-home.jpg differ diff --git a/qa/figma-123/implementation-measurement.jpg b/qa/figma-123/implementation-measurement.jpg new file mode 100644 index 0000000..18c8298 Binary files /dev/null and b/qa/figma-123/implementation-measurement.jpg differ diff --git a/qa/figma-123/implementation-preparation.jpg b/qa/figma-123/implementation-preparation.jpg new file mode 100644 index 0000000..91a4e01 Binary files /dev/null and b/qa/figma-123/implementation-preparation.jpg differ diff --git a/qa/figma-123/implementation-result.jpg b/qa/figma-123/implementation-result.jpg new file mode 100644 index 0000000..3e41546 Binary files /dev/null and b/qa/figma-123/implementation-result.jpg differ diff --git a/qa/figma-123/source-home.png b/qa/figma-123/source-home.png new file mode 100644 index 0000000..878fa35 Binary files /dev/null and b/qa/figma-123/source-home.png differ diff --git a/qa/figma-123/source-measurement.png b/qa/figma-123/source-measurement.png new file mode 100644 index 0000000..e22bd45 Binary files /dev/null and b/qa/figma-123/source-measurement.png differ diff --git a/qa/figma-123/source-preparation.png b/qa/figma-123/source-preparation.png new file mode 100644 index 0000000..d8b4840 Binary files /dev/null and b/qa/figma-123/source-preparation.png differ diff --git a/qa/figma-123/source-result.png b/qa/figma-123/source-result.png new file mode 100644 index 0000000..a9614c4 Binary files /dev/null and b/qa/figma-123/source-result.png differ diff --git a/qa/onboarding/step-1-welcome.jpg b/qa/onboarding/step-1-welcome.jpg new file mode 100644 index 0000000..990b97e Binary files /dev/null and b/qa/onboarding/step-1-welcome.jpg differ diff --git a/qa/onboarding/step-3-assessment.jpg b/qa/onboarding/step-3-assessment.jpg new file mode 100644 index 0000000..f5d905a Binary files /dev/null and b/qa/onboarding/step-3-assessment.jpg differ diff --git a/qa/onboarding/step-6-experience.jpg b/qa/onboarding/step-6-experience.jpg new file mode 100644 index 0000000..94c2a77 Binary files /dev/null and b/qa/onboarding/step-6-experience.jpg differ diff --git a/qa/onboarding/step-7-complete.jpg b/qa/onboarding/step-7-complete.jpg new file mode 100644 index 0000000..cf26c9e Binary files /dev/null and b/qa/onboarding/step-7-complete.jpg differ diff --git a/shared/regulation-protocols.v1.json b/shared/regulation-protocols.v1.json new file mode 100644 index 0000000..420e317 --- /dev/null +++ b/shared/regulation-protocols.v1.json @@ -0,0 +1,150 @@ +{ + "schemaVersion": 1, + "catalogVersion": "1.0.0", + "protocols": [ + { + "kind": "myCoherence", + "family": "breathe", + "mode": "livePPG", + "eyebrow": "BIOFEEDBACK", + "title": "Ma Cohérence", + "description": "Observez comment votre rythme pulsatile évolue avec un guide respiratoire 5–5.", + "durationSeconds": 180, + "allowedDurationsSeconds": [180, 300], + "intensity": "aucune", + "phases": [ + {"kind": "inhale", "durationSeconds": 5, "label": "Inspirez"}, + {"kind": "exhale", "durationSeconds": 5, "label": "Expirez"} + ], + "education": { + "definition": "Un entraînement de biofeedback qui rapproche progressivement les variations du pouls d’un rythme respiratoire guidé.", + "documentedBenefit": "Une respiration lente et régulière peut favoriser la régulation autonome et la variabilité du rythme cardiaque. Les effets varient selon les personnes.", + "moduleAction": "La caméra estime les intervalles entre pulsations. Haptika affiche un score expérimental de synchronisation avec le guide, sans prétendre mesurer directement la respiration ni remplacer un ECG.", + "sources": ["Lehrer & Gevirtz, Frontiers in Psychology, 2014", "Laborde et al., Frontiers in Neuroscience, 2022"] + } + }, + { + "kind": "coherence55", + "family": "breathe", + "mode": "haptic", + "eyebrow": "ÉQUILIBRE", + "title": "Cohérence 5–5", + "description": "Une respiration régulière pour retrouver un rythme calme.", + "durationSeconds": 300, + "allowedDurationsSeconds": [300], + "intensity": "modérée", + "phases": [ + {"kind": "inhale", "durationSeconds": 5, "label": "Inspirez"}, + {"kind": "exhale", "durationSeconds": 5, "label": "Expirez lentement"} + ], + "education": { + "definition": "Une respiration symétrique de cinq secondes à l’inspiration et cinq secondes à l’expiration.", + "documentedBenefit": "Une respiration lente et régulière peut favoriser la régulation autonome et la variabilité du rythme cardiaque. Les effets varient selon les personnes.", + "moduleAction": "L’onde visuelle et les vibrations donnent un repère régulier pendant cinq minutes.", + "sources": ["Lehrer & Gevirtz, Frontiers in Psychology, 2014", "Laborde et al., Frontiers in Neuroscience, 2022"] + } + }, + { + "kind": "box4444", + "family": "breathe", + "mode": "haptic", + "eyebrow": "STABILITÉ", + "title": "Respiration carrée", + "description": "Quatre temps égaux pour structurer l’attention.", + "durationSeconds": 240, + "allowedDurationsSeconds": [240], + "intensity": "douce", + "phases": [ + {"kind": "inhale", "durationSeconds": 4, "label": "Inspirez"}, + {"kind": "hold", "durationSeconds": 4, "label": "Gardez l’air"}, + {"kind": "exhale", "durationSeconds": 4, "label": "Expirez"}, + {"kind": "hold", "durationSeconds": 4, "label": "Restez vide"} + ], + "education": { + "definition": "Quatre phases de même durée : inspirer, retenir, expirer puis attendre.", + "documentedBenefit": "Une respiration lente et régulière peut favoriser la régulation autonome et la variabilité du rythme cardiaque. Les effets varient selon les personnes.", + "moduleAction": "Haptika matérialise chaque côté du cycle par une texture visuelle et tactile distincte.", + "sources": ["Lehrer & Gevirtz, Frontiers in Psychology, 2014", "Laborde et al., Frontiers in Neuroscience, 2022"] + } + }, + { + "kind": "breathing478", + "family": "breathe", + "mode": "haptic", + "eyebrow": "RALENTISSEMENT", + "title": "Respiration 4–7–8", + "description": "Un cycle court avec une expiration longue.", + "durationSeconds": 76, + "allowedDurationsSeconds": [76], + "intensity": "douce", + "phases": [ + {"kind": "inhale", "durationSeconds": 4, "label": "Inspirez"}, + {"kind": "hold", "durationSeconds": 7, "label": "Gardez l’air"}, + {"kind": "exhale", "durationSeconds": 8, "label": "Expirez lentement"} + ], + "education": { + "definition": "Quatre cycles composés d’une inspiration de 4 secondes, d’une pause de 7 secondes et d’une expiration de 8 secondes.", + "documentedBenefit": "Une respiration lente et régulière peut favoriser la régulation autonome et la variabilité du rythme cardiaque. Les effets varient selon les personnes.", + "moduleAction": "Le guide compte les quatre cycles et s’arrête automatiquement après 76 secondes.", + "sources": ["Lehrer & Gevirtz, Frontiers in Psychology, 2014", "Laborde et al., Frontiers in Neuroscience, 2022"] + } + }, + { + "kind": "relax46", + "family": "breathe", + "mode": "haptic", + "eyebrow": "RELÂCHEMENT", + "title": "Expiration 4–6", + "description": "Une expiration plus longue pour accompagner la détente.", + "durationSeconds": 180, + "allowedDurationsSeconds": [180], + "intensity": "douce", + "phases": [ + {"kind": "inhale", "durationSeconds": 4, "label": "Inspirez"}, + {"kind": "exhale", "durationSeconds": 6, "label": "Expirez lentement"} + ], + "education": { + "definition": "Une respiration où l’expiration dure deux secondes de plus que l’inspiration.", + "documentedBenefit": "Une respiration lente et régulière peut favoriser la régulation autonome et la variabilité du rythme cardiaque. Les effets varient selon les personnes.", + "moduleAction": "La vibration devient plus douce pendant l’expiration afin d’accompagner le relâchement.", + "sources": ["Lehrer & Gevirtz, Frontiers in Psychology, 2014", "Laborde et al., Frontiers in Neuroscience, 2022"] + } + }, + { + "kind": "sosStress", + "family": "recenter", + "mode": "haptic", + "eyebrow": "RETOUR AU CALME", + "title": "SOS tension", + "description": "Trois impulsions puis un repère toutes les dix secondes.", + "durationSeconds": 62, + "allowedDurationsSeconds": [62], + "intensity": "présente", + "phases": [{"kind": "anchor", "durationSeconds": 62, "label": "Restez avec la sensation"}], + "education": { + "definition": "Un repère tactile bref conçu pour interrompre une montée de tension ressentie.", + "documentedBenefit": "Ramener volontairement l’attention vers une sensation présente peut faciliter le recentrage. Ce module ne constitue pas un traitement.", + "moduleAction": "Trois impulsions attirent l’attention, puis un repère discret toutes les dix secondes accompagne le retour aux sensations.", + "sources": ["Approche d’ancrage attentionnel — usage de bien-être non médical"] + } + }, + { + "kind": "microAnchor", + "family": "ground", + "mode": "haptic", + "eyebrow": "FORMAT COURT", + "title": "Micro-ancrage", + "description": "Un repère tactile discret pour revenir aux sensations.", + "durationSeconds": 37, + "allowedDurationsSeconds": [37], + "intensity": "douce", + "phases": [{"kind": "anchor", "durationSeconds": 37, "label": "Sentez les points de contact"}], + "education": { + "definition": "Un exercice très court d’attention aux sensations tactiles.", + "documentedBenefit": "L’orientation vers les sensations présentes peut aider certaines personnes à diminuer la rumination momentanée.", + "moduleAction": "Haptika fournit un repère tactile discret, sans mesurer ni diagnostiquer votre état.", + "sources": ["Approche d’ancrage attentionnel — usage de bien-être non médical"] + } + } + ] +} diff --git a/shared/watch-sync-fixtures.v1.json b/shared/watch-sync-fixtures.v1.json new file mode 100644 index 0000000..cccf140 --- /dev/null +++ b/shared/watch-sync-fixtures.v1.json @@ -0,0 +1,66 @@ +{ + "schemaVersion": 1, + "measurementEnvelope": { + "schemaVersion": 1, + "eventId": "11111111-1111-4111-8111-111111111111", + "deviceId": "watch-series-10-test", + "sequence": 42, + "sentAt": "2026-07-20T09:00:01.000Z", + "kind": "measurementUpsert", + "payload": { + "schemaVersion": 1, + "id": "22222222-2222-4222-8222-222222222222", + "updatedAt": "2026-07-20T09:00:01.000Z", + "startedAt": "2026-07-20T08:59:00.000Z", + "endedAt": "2026-07-20T09:00:00.000Z", + "source": "appleWatchOptical", + "device": { + "id": "watch-series-10-test", + "name": "Apple Watch de test", + "model": "Watch7,8", + "systemVersion": "11.0" + }, + "currentBpm": 72, + "averageBpm": 71.5, + "minimumBpm": 68, + "maximumBpm": 75, + "samples": [ + {"offsetSeconds": 0, "bpm": 70}, + {"offsetSeconds": 7, "bpm": 71}, + {"offsetSeconds": 14, "bpm": 72}, + {"offsetSeconds": 21, "bpm": 70}, + {"offsetSeconds": 28, "bpm": 73}, + {"offsetSeconds": 35, "bpm": 71}, + {"offsetSeconds": 42, "bpm": 73}, + {"offsetSeconds": 50, "bpm": 72} + ], + "coverage": 0.8333333333, + "quality": 0.83, + "valid": true, + "invalidReason": null, + "context": { + "atRest": true, + "posture": "seated", + "recentExercise": false, + "recentCaffeine": false, + "recentMeal": false + }, + "feeling": {"score": 4, "recordedAt": "2026-07-20T09:00:00.000Z"} + } + }, + "tombstoneEnvelope": { + "schemaVersion": 1, + "eventId": "33333333-3333-4333-8333-333333333333", + "deviceId": "watch-series-10-test", + "sequence": 43, + "sentAt": "2026-07-20T09:05:00.000Z", + "kind": "tombstone", + "payload": { + "schemaVersion": 1, + "entityId": "22222222-2222-4222-8222-222222222222", + "entityKind": "measurement", + "deletedAt": "2026-07-20T09:05:00.000Z", + "updatedAt": "2026-07-20T09:05:00.000Z" + } + } +} diff --git a/src/features/cardiacMirror/CardiacMirrorScreen.tsx b/src/features/cardiacMirror/CardiacMirrorScreen.tsx new file mode 100644 index 0000000..627171b --- /dev/null +++ b/src/features/cardiacMirror/CardiacMirrorScreen.tsx @@ -0,0 +1,251 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {StyleSheet, Text, TextInput, View} from 'react-native'; + +import {colors, radii, spacing} from '../../theme'; +import {PrimaryButton} from '../ppg/components/PrimaryButton'; +import { + cardiacMirrorPatternInfo, + targetAtElapsed, +} from './cardiacMirrorEngine'; +import {cardiacMirrorNative} from './cardiacMirrorService'; +import type {CardiacMirrorMode} from './cardiacMirrorTypes'; + +interface CardiacMirrorScreenProps { + onBack: () => void; +} + +export function CardiacMirrorScreen({ + onBack, +}: CardiacMirrorScreenProps): React.JSX.Element { + const [mode, setMode] = useState('realtime'); + const [restingBpm, setRestingBpm] = useState(''); + const [isRunning, setIsRunning] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [visualPulse, setVisualPulse] = useState(false); + const [watchReachable, setWatchReachable] = useState(false); + const [watchBpm, setWatchBpm] = useState(null); + const [watchStartBpm, setWatchStartBpm] = useState(null); + const watchStartBpmRef = useRef(null); + const lastHeartbeatTimestampRef = useRef(null); + const nextPulseAtRef = useRef(0); + const pulseTimerRef = useRef | null>(null); + const pulseResetTimerRef = useRef | null>(null); + + const startBpm = Number(restingBpm); + const effectiveStartBpm = mode === 'realtime' ? watchStartBpm ?? 0 : startBpm; + const hasValidStartBpm = Number.isFinite(effectiveStartBpm) && effectiveStartBpm >= 40 && effectiveStartBpm <= 180; + const target = useMemo( + () => (hasValidStartBpm ? targetAtElapsed(effectiveStartBpm, elapsedSeconds) : null), + [effectiveStartBpm, elapsedSeconds, hasValidStartBpm], + ); + + const stop = useCallback(() => { + if (pulseTimerRef.current !== null) { + clearInterval(pulseTimerRef.current); + pulseTimerRef.current = null; + } + if (pulseResetTimerRef.current !== null) { + clearTimeout(pulseResetTimerRef.current); + pulseResetTimerRef.current = null; + } + cardiacMirrorNative.stop(); + setIsRunning(false); + setWatchBpm(null); + setWatchStartBpm(null); + watchStartBpmRef.current = null; + lastHeartbeatTimestampRef.current = null; + }, []); + + const emitPulse = useCallback(() => { + cardiacMirrorNative.pulse(); + setVisualPulse(true); + if (pulseResetTimerRef.current !== null) { + clearTimeout(pulseResetTimerRef.current); + } + pulseResetTimerRef.current = setTimeout(() => setVisualPulse(false), 180); + }, []); + + useEffect(() => stop, [stop]); + + useEffect(() => { + if (mode !== 'realtime') return undefined; + cardiacMirrorNative.startWatchSession(); + const watchPoller = setInterval(() => { + setWatchReachable(cardiacMirrorNative.watchReachable()); + const heartbeat = cardiacMirrorNative.latestHeartbeat(); + if ( + heartbeat === undefined || + heartbeat.timestampSeconds === lastHeartbeatTimestampRef.current + ) { + return; + } + lastHeartbeatTimestampRef.current = heartbeat.timestampSeconds; + setWatchBpm(heartbeat.bpm); + if (watchStartBpmRef.current === null) { + watchStartBpmRef.current = heartbeat.bpm; + setWatchStartBpm(heartbeat.bpm); + const startedAtMs = Date.now(); + nextPulseAtRef.current = startedAtMs; + cardiacMirrorNative.start(heartbeat.bpm); + setIsRunning(true); + pulseTimerRef.current = setInterval(() => { + const elapsed = (Date.now() - startedAtMs) / 1000; + setElapsedSeconds(elapsed); + const reconstructedTarget = targetAtElapsed(heartbeat.bpm, elapsed); + const currentTime = Date.now(); + if (currentTime >= nextPulseAtRef.current) { + emitPulse(); + nextPulseAtRef.current = + currentTime + reconstructedTarget.intervalSeconds * 1000; + } + }, 50); + } + }, 100); + return () => clearInterval(watchPoller); + }, [emitPulse, mode]); + + const start = useCallback(() => { + if (!hasValidStartBpm || mode !== 'estimation') return; + cardiacMirrorNative.start(startBpm); + const now = Date.now(); + nextPulseAtRef.current = now; + setElapsedSeconds(0); + setIsRunning(true); + pulseTimerRef.current = setInterval(() => { + const elapsed = (Date.now() - now) / 1000; + setElapsedSeconds(elapsed); + const currentTarget = targetAtElapsed(startBpm, elapsed); + const currentTime = Date.now(); + if (currentTime >= nextPulseAtRef.current) { + emitPulse(); + nextPulseAtRef.current = currentTime + currentTarget.intervalSeconds * 1000; + } + }, 50); + }, [emitPulse, hasValidStartBpm, mode, startBpm]); + + const pattern = cardiacMirrorPatternInfo; + + return ( + + + + MODULE 7 · MIROIR CARDIAQUE + + + Sentir son cœur battre en miroir. + + La fréquence mesurée sert de point de départ à un rythme visuel et + haptique reconstruit, puis ralenti de 1 à 2 BPM par minute. + + + + {(['realtime', 'estimation'] as const).map(value => ( + { + stop(); + setMode(value); + }} + style={styles.modeButton} + /> + ))} + + + + + + + {mode === 'realtime' ? 'Mode Temps réel' : 'Mode Estimation'} + + + {mode === 'realtime' + ? watchReachable + ? 'Apple Watch connectée : ses mises à jour de fréquence pilotent un rythme reconstruit sur l’iPhone.' + : 'En attente d’une Apple Watch connectée. Le miroir démarrera après une fréquence valide.' + : 'L’iPhone utilise une fréquence de départ saisie manuellement, sans prétendre mesurer chaque battement.'} + + + + + {mode === 'estimation' && ( + + Fréquence de départ (BPM) + + + Utilisez votre fréquence au repos habituelle ou une valeur mesurée juste avant. + + + )} + + + + Cible actuelle + + {target === null ? '—' : `${Math.round(target.bpm)} BPM`} + + + {mode === 'realtime' && ( + + Watch + {watchBpm === null ? '—' : `${Math.round(watchBpm)} BPM`} + + )} + + Durée + {Math.floor(elapsedSeconds)} s + + + + + {mode === 'realtime' && ( + + La Watch mesure sans vibrer. L’iPhone produit ensuite le visuel et la + texture haptique ; ce n’est pas chaque battement biologique exact. + + )} + + + Pattern {pattern.id} · v{pattern.version} · rythme reconstruit + + + ); +} + +const styles = StyleSheet.create({ + container: {flex: 1, padding: spacing.lg, backgroundColor: colors.background}, + header: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'}, + kicker: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1}, + title: {color: colors.text, fontSize: 36, lineHeight: 40, fontWeight: '800', marginTop: spacing.xl}, + subtitle: {color: colors.muted, fontSize: 16, lineHeight: 24, marginTop: spacing.md}, + modeRow: {flexDirection: 'row', gap: spacing.xs, marginTop: spacing.lg}, + modeButton: {flex: 1, minHeight: 52, paddingHorizontal: spacing.sm}, + statusCard: {flexDirection: 'row', alignItems: 'center', marginTop: spacing.lg, padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surfaceElevated}, + pulse: {width: 42, height: 42, borderRadius: 21, backgroundColor: colors.border, marginRight: spacing.md}, + pulseActive: {backgroundColor: colors.accent, transform: [{scale: 1.18}]}, + statusCopy: {flex: 1}, + statusTitle: {color: colors.text, fontSize: 17, fontWeight: '800'}, + statusText: {color: colors.muted, lineHeight: 19, marginTop: spacing.xs}, + inputCard: {marginTop: spacing.md, padding: spacing.md, borderRadius: radii.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface}, + inputLabel: {color: colors.text, fontWeight: '700'}, + input: {color: colors.text, fontSize: 28, fontWeight: '800', paddingVertical: spacing.sm, borderBottomWidth: 1, borderBottomColor: colors.border}, + helperText: {color: colors.muted, fontSize: 12, lineHeight: 17, marginTop: spacing.xs}, + metricsCard: {flexDirection: 'row', justifyContent: 'space-around', marginVertical: spacing.lg, padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surface}, + metricLabel: {color: colors.muted, fontSize: 12}, + metricValue: {color: colors.text, fontSize: 25, fontWeight: '800', marginTop: spacing.xs}, + notice: {color: colors.medium, fontSize: 12, lineHeight: 17, textAlign: 'center', marginTop: spacing.sm}, + ipNote: {color: colors.muted, fontSize: 10, textAlign: 'center', marginTop: 'auto'}, +}); diff --git a/src/features/cardiacMirror/HapticWaveExerciseScreen.tsx b/src/features/cardiacMirror/HapticWaveExerciseScreen.tsx new file mode 100644 index 0000000..aeab0bf --- /dev/null +++ b/src/features/cardiacMirror/HapticWaveExerciseScreen.tsx @@ -0,0 +1,412 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import { + Animated, + 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 '../ppg/components/PrimaryButton'; +import {cardiacMirrorNative} from './cardiacMirrorService'; +import {ProtocolInfoSheet} from './ProtocolInfoSheet'; +import {activeRegulationPhase} from './regulationPhaseEngine'; +import { + formatProtocolDuration, + getRegulationProtocol, + HAPTIC_REGULATION_PROTOCOLS, + type HapticRegulationProtocol, + type HapticRegulationProtocolKind, +} from './regulationProtocols'; + +interface HapticWaveExerciseScreenProps { + onBack: () => void; + initialProtocolKind?: HapticRegulationProtocolKind; + onCompleted?: (summary: HapticExerciseSummary) => void; + onCancelled?: (summary: HapticExerciseSummary) => void; + onRequestStart?: (protocol: HapticRegulationProtocol) => boolean; +} + +export interface HapticExerciseSummary { + protocolKind: HapticRegulationProtocolKind; + startedAt: string; + completedAt: string; + elapsedSeconds: number; + completed: boolean; +} + +export function HapticWaveExerciseScreen({ + onBack, + initialProtocolKind = 'coherence55', + onCompleted, + onCancelled, + onRequestStart, +}: HapticWaveExerciseScreenProps): React.JSX.Element { + const insets = useSafeAreaInsets(); + const [selectedKind, setSelectedKind] = + useState(initialProtocolKind); + const [running, setRunning] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [hapticsAvailable, setHapticsAvailable] = useState(true); + const [infoProtocol, setInfoProtocol] = useState(null); + const waveScale = useRef(new Animated.Value(0.7)).current; + const waveOpacity = useRef(new Animated.Value(0.55)).current; + const animationRef = useRef(null); + const timerRef = useRef | null>(null); + const sessionStartedAtRef = useRef(0); + + const selectedProtocol = useMemo( + () => getRegulationProtocol(selectedKind), + [selectedKind], + ); + const phase = activeRegulationPhase(selectedProtocol, elapsedSeconds); + const progress = Math.min( + 1, + elapsedSeconds / selectedProtocol.durationSeconds, + ); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const stop = useCallback(() => { + clearTimer(); + animationRef.current?.stop(); + animationRef.current = null; + waveScale.stopAnimation(); + waveOpacity.stopAnimation(); + waveScale.setValue(0.7); + waveOpacity.setValue(0.55); + cardiacMirrorNative.stopRegulationProtocol(); + setRunning(false); + setElapsedSeconds(0); + }, [clearTimer, waveOpacity, waveScale]); + + const summary = useCallback( + (completed: boolean, elapsed: number): HapticExerciseSummary => ({ + protocolKind: selectedKind, + startedAt: new Date(sessionStartedAtRef.current).toISOString(), + completedAt: new Date().toISOString(), + elapsedSeconds: Math.max(0, elapsed), + completed, + }), + [selectedKind], + ); + + useEffect(() => { + setHapticsAvailable(cardiacMirrorNative.isSupported()); + return stop; + }, [stop]); + + const startWaveAnimation = useCallback( + (protocol: HapticRegulationProtocol) => { + const sequence = protocol.phases[0]?.kind === 'anchor' + ? Animated.sequence([ + Animated.parallel([ + Animated.timing(waveScale, { + toValue: 1.03, + duration: 2600, + useNativeDriver: true, + }), + Animated.timing(waveOpacity, { + toValue: 0.82, + duration: 2600, + useNativeDriver: true, + }), + ]), + Animated.parallel([ + Animated.timing(waveScale, { + toValue: 0.76, + duration: 3400, + useNativeDriver: true, + }), + Animated.timing(waveOpacity, { + toValue: 0.48, + duration: 3400, + useNativeDriver: true, + }), + ]), + ]) + : Animated.sequence(protocol.phases.map(phaseItem => { + const expanding = phaseItem.kind === 'inhale'; + const contracting = phaseItem.kind === 'exhale'; + if (!expanding && !contracting) { + return Animated.delay(phaseItem.durationSeconds * 1000); + } + return Animated.parallel([ + Animated.timing(waveScale, { + toValue: expanding ? 1.12 : 0.7, + duration: phaseItem.durationSeconds * 1000, + useNativeDriver: true, + }), + Animated.timing(waveOpacity, { + toValue: expanding ? 0.92 : 0.48, + duration: phaseItem.durationSeconds * 1000, + useNativeDriver: true, + }), + ]); + })); + const animation = Animated.loop(sequence); + animationRef.current = animation; + animation.start(); + }, + [waveOpacity, waveScale], + ); + + const start = useCallback(() => { + if (running) return; + if (onRequestStart?.(selectedProtocol) === true) return; + const nativeDuration = cardiacMirrorNative.playRegulationProtocol( + selectedProtocol.kind, + ); + if (nativeDuration <= 0) { + setHapticsAvailable(false); + } + + sessionStartedAtRef.current = Date.now(); + setElapsedSeconds(0); + setRunning(true); + startWaveAnimation(selectedProtocol); + timerRef.current = setInterval(() => { + const elapsed = (Date.now() - sessionStartedAtRef.current) / 1000; + if (elapsed >= selectedProtocol.durationSeconds) { + const completedSummary = summary(true, elapsed); + stop(); + onCompleted?.(completedSummary); + return; + } + setElapsedSeconds(elapsed); + }, 100); + }, [onCompleted, onRequestStart, running, selectedProtocol, startWaveAnimation, stop, summary]); + + const cancelExercise = useCallback(() => { + if (!running) { + onBack(); + return; + } + const elapsed = (Date.now() - sessionStartedAtRef.current) / 1000; + const cancelledSummary = summary(false, elapsed); + stop(); + onCancelled?.(cancelledSummary); + onBack(); + }, [onBack, onCancelled, running, stop, summary]); + + const phaseLabel = phase.phase.label; + + return ( + + + + + HAPTIKA + RÉGULATION HAPTIQUE + + + + {!running ? ( + <> + + CHOISISSEZ VOTRE RYTHME + Le calme au creux de la main. + + Choisissez un rythme, gardez l’iPhone en main et laissez les + vibrations guider votre respiration. + + + + + Choisir un exercice + {HAPTIC_REGULATION_PROTOCOLS.length} RYTHMES + + + + {HAPTIC_REGULATION_PROTOCOLS.map((protocol, index) => { + const selected = protocol.kind === selectedKind; + return ( + setSelectedKind(protocol.kind)} + style={({pressed}) => [ + styles.protocolCard, + selected && styles.protocolCardSelected, + pressed && styles.protocolCardPressed, + ]}> + + + {String(index + 1).padStart(2, '0')} + + + + + {protocol.family === 'breathe' ? 'RESPIRER' : protocol.family === 'ground' ? 'ANCRER' : 'RECENTRER'} · {protocol.eyebrow} + setInfoProtocol(protocol)} style={styles.infoButton}> + i + + + {protocol.title} + {selected ? ( + {protocol.description} + ) : null} + + + {formatProtocolDuration(protocol.durationSeconds)} + + + + {protocol.mode === 'livePPG' ? 'caméra · sans vibration' : `vibration ${protocol.intensity}`} + + + + + + ); + })} + + + + {!hapticsAvailable && selectedProtocol.mode === 'haptic' && ( + + Les vibrations ne sont pas disponibles. Le guide visuel reste utilisable. + + )} + + ) : ( + + {selectedProtocol.eyebrow} + {selectedProtocol.title} + Gardez le téléphone dans votre main. + + + + + + {phaseLabel} + {phase.remainingSeconds} + + + + PROGRESSION + {Math.round(progress * 100)} % + + + + + + + + + Ce protocole accompagne la régulation ressentie. Il ne mesure pas + et ne diagnostique pas votre niveau de stress. + + + + + + )} + setInfoProtocol(null)} /> + + ); +} + +const styles = StyleSheet.create({ + scroll: {flex: 1, backgroundColor: colors.background}, + content: {padding: spacing.lg, paddingBottom: spacing.xxl, gap: spacing.md}, + header: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'}, + brandBlock: {alignItems: 'flex-end'}, + brand: {color: colors.text, fontSize: 15, fontWeight: '900', letterSpacing: 2.2}, + kicker: {color: colors.accent, fontSize: 9, fontWeight: '800', letterSpacing: 1.2, marginTop: 3}, + hero: {paddingTop: spacing.sm, paddingBottom: spacing.sm}, + eyebrow: {color: colors.accent, fontSize: 11, fontWeight: '800', letterSpacing: 1.8}, + title: {color: colors.text, fontSize: 34, lineHeight: 39, fontWeight: '800', letterSpacing: -1.2, marginTop: spacing.sm, maxWidth: 330}, + subtitle: {color: colors.muted, fontSize: 16, lineHeight: 24, marginTop: spacing.md, maxWidth: 350}, + heroOrb: {alignSelf: 'center', width: 182, height: 182, borderRadius: 91, borderWidth: 1, borderColor: '#174E78', alignItems: 'center', justifyContent: 'center', backgroundColor: '#041128', marginTop: spacing.xl}, + heroOrbMiddle: {width: 126, height: 126, borderRadius: 63, borderWidth: 1, borderColor: '#287BAA', alignItems: 'center', justifyContent: 'center', backgroundColor: '#082345'}, + heroOrbCore: {width: 66, height: 66, borderRadius: 33, backgroundColor: colors.accent, borderWidth: 8, borderColor: '#B7ECFF'}, + sectionHeader: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'}, + sectionTitle: {color: colors.text, fontSize: 24, fontWeight: '800'}, + sectionCount: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.2}, + protocolList: {gap: spacing.sm}, + protocolCard: {minHeight: 96, flexDirection: 'row', alignItems: 'flex-start', padding: spacing.md, borderRadius: radii.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface}, + protocolCardSelected: {borderColor: colors.accent, backgroundColor: colors.surfaceElevated}, + protocolCardPressed: {opacity: 0.82}, + protocolIndex: {width: 42, height: 42, borderRadius: 21, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0A2746', borderWidth: 1, borderColor: colors.border}, + protocolIndexSelected: {backgroundColor: colors.accent, borderColor: colors.accent}, + protocolIndexText: {color: colors.muted, fontSize: 11, fontWeight: '800'}, + protocolIndexTextSelected: {color: colors.background}, + protocolCopy: {flex: 1, paddingHorizontal: spacing.md}, + protocolHeadingRow: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between'}, + infoButton: {width: 32, height: 32, borderRadius: 16, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.border}, + infoButtonText: {color: colors.accent, fontSize: 16, fontWeight: '800'}, + protocolEyebrow: {color: colors.accent, fontSize: 9, fontWeight: '800', letterSpacing: 1.2}, + protocolTitle: {color: colors.text, fontSize: 20, fontWeight: '800', marginTop: 3}, + protocolDescription: {color: colors.muted, fontSize: 13, lineHeight: 18, marginTop: spacing.xs}, + protocolMeta: {flexDirection: 'row', alignItems: 'center', marginTop: spacing.sm}, + protocolMetaText: {color: '#C8E5F8', fontSize: 11}, + metaDot: {width: 3, height: 3, borderRadius: 2, backgroundColor: colors.accent, marginHorizontal: spacing.xs}, + selectionDot: {width: 18, height: 18, borderRadius: 9, borderWidth: 1, borderColor: colors.border, marginTop: 3}, + selectionDotSelected: {borderWidth: 5, borderColor: colors.accent, backgroundColor: colors.background}, + startPanel: {flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: spacing.md, borderRadius: radii.md, backgroundColor: '#071A33'}, + startPanelLabel: {color: colors.accent, fontSize: 9, fontWeight: '800', letterSpacing: 1.2}, + startPanelTitle: {color: colors.text, fontSize: 17, fontWeight: '800', marginTop: 3}, + startPanelDuration: {color: colors.text, fontSize: 15, fontWeight: '700'}, + unavailableText: {color: colors.poor, textAlign: 'center', lineHeight: 19}, + activeSession: {paddingTop: spacing.xl, gap: spacing.md}, + activeEyebrow: {color: colors.accent, fontSize: 11, fontWeight: '800', letterSpacing: 1.8, textAlign: 'center'}, + activeTitle: {color: colors.text, fontSize: 36, fontWeight: '800', textAlign: 'center'}, + activeSubtitle: {color: colors.muted, fontSize: 15, textAlign: 'center'}, + waveArea: {height: 390, alignItems: 'center', justifyContent: 'center', overflow: 'hidden'}, + wave: {position: 'absolute', borderRadius: 999, borderWidth: 1}, + waveOuter: {width: 310, height: 310, borderColor: '#1B5B86', backgroundColor: '#041229'}, + waveMiddle: {width: 230, height: 230, borderColor: '#2B8ABC', backgroundColor: '#082143'}, + waveInner: {width: 142, height: 142, borderColor: '#B7ECFF', borderWidth: 2, backgroundColor: colors.accentSoft}, + phaseLabel: {color: colors.text, fontSize: 23, fontWeight: '800', textAlign: 'center', maxWidth: 170}, + phaseCountdown: {color: colors.accent, fontSize: 34, fontWeight: '300', fontVariant: ['tabular-nums'], marginTop: spacing.xs}, + progressHeader: {flexDirection: 'row', justifyContent: 'space-between'}, + progressLabel: {color: colors.muted, fontSize: 10, fontWeight: '800', letterSpacing: 1.2}, + progressValue: {color: colors.accent, fontWeight: '800', fontVariant: ['tabular-nums']}, + progressTrack: {height: 6, borderRadius: 3, backgroundColor: colors.surfaceElevated, overflow: 'hidden'}, + progressFill: {height: 6, borderRadius: 3, backgroundColor: colors.accent}, + sessionNote: {flexDirection: 'row', padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surface}, + sessionNoteDot: {width: 8, height: 8, borderRadius: 4, backgroundColor: colors.accent, marginTop: 5, marginRight: spacing.sm}, + sessionNoteText: {flex: 1, color: colors.muted, fontSize: 12, lineHeight: 18}, +}); diff --git a/src/features/cardiacMirror/ProtocolInfoSheet.tsx b/src/features/cardiacMirror/ProtocolInfoSheet.tsx new file mode 100644 index 0000000..60b0401 --- /dev/null +++ b/src/features/cardiacMirror/ProtocolInfoSheet.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {Modal, Pressable, ScrollView, StyleSheet, Text, View} from 'react-native'; + +import {colors, radii, spacing} from '../../theme'; +import type {HapticRegulationProtocol} from './regulationProtocols'; + +interface ProtocolInfoSheetProps { + protocol: HapticRegulationProtocol | null; + onClose: () => void; +} + +export function ProtocolInfoSheet({protocol, onClose}: ProtocolInfoSheetProps): React.JSX.Element { + return ( + + + + {protocol !== null ? ( + + + + À PROPOS DU PROTOCOLE + {protocol.title} + + + + + SOURCES ET CADRE + {protocol.education.sources.map(source => ( + • {source} + ))} + + + Outil de bien-être non médical. Il ne pose aucun diagnostic et ne remplace pas un professionnel de santé. + + + J’ai compris + + + + ) : null} + + + ); +} + +function InfoSection({title, body}: {title: string; body: string}): React.JSX.Element { + return {title}{body}; +} + +const styles = StyleSheet.create({ + backdrop: {flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,5,15,0.72)'}, + sheet: {maxHeight: '86%', backgroundColor: colors.surface, borderTopLeftRadius: 30, borderTopRightRadius: 30, borderWidth: 1, borderColor: colors.border, overflow: 'hidden'}, + handle: {width: 42, height: 5, borderRadius: 3, backgroundColor: colors.border, alignSelf: 'center', marginTop: spacing.sm}, + content: {padding: spacing.lg, paddingBottom: spacing.xxl, gap: spacing.md}, + eyebrow: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.7}, + title: {color: colors.text, fontSize: 30, fontWeight: '800'}, + section: {gap: spacing.xs, paddingTop: spacing.sm}, + sectionTitle: {color: colors.text, fontSize: 17, fontWeight: '800'}, + body: {color: colors.muted, fontSize: 15, lineHeight: 22}, + sourceBlock: {padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surfaceElevated, gap: spacing.xs}, + sourceTitle: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.2}, + sourceText: {color: colors.muted, fontSize: 12, lineHeight: 18}, + disclaimer: {color: colors.muted, fontSize: 12, lineHeight: 18}, + button: {minHeight: 54, borderRadius: radii.md, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.accent}, + buttonText: {color: colors.background, fontSize: 16, fontWeight: '900'}, +}); diff --git a/src/features/cardiacMirror/cardiacMirrorEngine.test.ts b/src/features/cardiacMirror/cardiacMirrorEngine.test.ts new file mode 100644 index 0000000..595ca7e --- /dev/null +++ b/src/features/cardiacMirror/cardiacMirrorEngine.test.ts @@ -0,0 +1,35 @@ +import { + decelerationBpmPerMinute, + targetBpmAtElapsed, + targetForHeartbeat, +} from './cardiacMirrorEngine'; + +describe('cardiac mirror engine', () => { + it('uses a dynamic starting heart rate', () => { + expect(targetBpmAtElapsed(72, 0)).toBe(72); + expect(targetBpmAtElapsed(72, 60)).toBeCloseTo(70.8, 4); + }); + + it('keeps the deceleration in the specified training band', () => { + expect(decelerationBpmPerMinute(45)).toBe(1); + expect(decelerationBpmPerMinute(72)).toBeCloseTo(1.2, 4); + expect(decelerationBpmPerMinute(150)).toBe(2); + }); + + it('never creates a real-time beat without a valid Watch heartbeat', () => { + expect( + targetForHeartbeat(72, 60, { + timestampSeconds: 1, + bpm: 78, + source: 'watch', + }), + ).toMatchObject({bpm: 70.8}); + expect( + targetForHeartbeat(72, 60, { + timestampSeconds: 1, + bpm: 30, + source: 'watch', + }), + ).toBeUndefined(); + }); +}); diff --git a/src/features/cardiacMirror/cardiacMirrorEngine.ts b/src/features/cardiacMirror/cardiacMirrorEngine.ts new file mode 100644 index 0000000..fa9c8a0 --- /dev/null +++ b/src/features/cardiacMirror/cardiacMirrorEngine.ts @@ -0,0 +1,86 @@ +import type { + CardiacMirrorHeartbeat, + CardiacMirrorTarget, +} from './cardiacMirrorTypes'; + +export const CARDIAC_MIRROR_PATTERN_ID = 'HAPTIKA.CARDIAC_MIRROR_BEAT'; +export const CARDIAC_MIRROR_PATTERN_VERSION = '1.0.0'; +export const CARDIAC_MIRROR_MINIMUM_BPM = 40; + +export const cardiacMirrorPatternInfo = { + id: CARDIAC_MIRROR_PATTERN_ID, + version: CARDIAC_MIRROR_PATTERN_VERSION, +} as const; + +function finitePositive(value: number): number { + return Number.isFinite(value) && value > 0 ? value : 60; +} + +/** + * Decelerates faster from a higher starting rate while staying in the + * intentionally small 1–2 BPM/minute training band. + */ +export function decelerationBpmPerMinute(startBpm: number): number { + const start = finitePositive(startBpm); + return Math.min(2, Math.max(1, 1 + (start - 60) / 60)); +} + +export function targetBpmAtElapsed( + startBpm: number, + elapsedSeconds: number, +): number { + const start = finitePositive(startBpm); + const elapsed = Math.max(0, Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0); + const target = start - decelerationBpmPerMinute(start) * (elapsed / 60); + return Math.max(CARDIAC_MIRROR_MINIMUM_BPM, target); +} + +export function targetAtElapsed( + startBpm: number, + elapsedSeconds: number, +): CardiacMirrorTarget { + const bpm = targetBpmAtElapsed(startBpm, elapsedSeconds); + return { + bpm, + intervalSeconds: 60 / bpm, + elapsedSeconds: Math.max(0, elapsedSeconds), + }; +} + +export function sanitizeHeartbeat( + heartbeat: CardiacMirrorHeartbeat, +): CardiacMirrorHeartbeat | undefined { + if ( + heartbeat.source !== 'watch' || + !Number.isFinite(heartbeat.timestampSeconds) || + !Number.isFinite(heartbeat.bpm) || + heartbeat.bpm < 35 || + heartbeat.bpm > 220 + ) { + return undefined; + } + return heartbeat; +} + +/** + * A Watch cadence sample updates the reconstructed target. Public workout APIs + * do not expose a reliable biological beat event, so this function must never + * be presented as beat-to-beat synchronization. + */ +export function targetForHeartbeat( + startBpm: number, + elapsedSeconds: number, + heartbeat: CardiacMirrorHeartbeat, +): CardiacMirrorTarget | undefined { + const cleanHeartbeat = sanitizeHeartbeat(heartbeat); + if (cleanHeartbeat === undefined) return undefined; + + const target = targetAtElapsed(startBpm, elapsedSeconds); + return { + ...target, + // Keep the target below the observed rate, but do not create a sudden or + // perceptible jump when the observed rate changes. + bpm: Math.min(target.bpm, cleanHeartbeat.bpm), + intervalSeconds: 60 / Math.min(target.bpm, cleanHeartbeat.bpm), + }; +} diff --git a/src/features/cardiacMirror/cardiacMirrorService.ts b/src/features/cardiacMirror/cardiacMirrorService.ts new file mode 100644 index 0000000..7f8cdf4 --- /dev/null +++ b/src/features/cardiacMirror/cardiacMirrorService.ts @@ -0,0 +1,35 @@ +import {haptikaCardiacMirror} from 'react-native-haptika-ppg'; + +import { + CARDIAC_MIRROR_PATTERN_ID, + CARDIAC_MIRROR_PATTERN_VERSION, +} from './cardiacMirrorEngine'; +import type {CardiacMirrorNative} from './cardiacMirrorTypes'; + +export const cardiacMirrorNative: CardiacMirrorNative = { + isSupported: () => haptikaCardiacMirror.isSupported(), + patternInfo: () => { + const pattern = haptikaCardiacMirror.patternInfo(); + return {id: pattern.id, version: pattern.version}; + }, + start: bpm => haptikaCardiacMirror.start(bpm), + pulse: () => haptikaCardiacMirror.pulse(), + stop: () => haptikaCardiacMirror.stop(), + startWatchSession: () => haptikaCardiacMirror.startWatchSession(), + watchReachable: () => haptikaCardiacMirror.watchReachable(), + latestHeartbeat: () => { + const heartbeat = haptikaCardiacMirror.latestHeartbeat(); + return heartbeat === undefined + ? undefined + : {timestampSeconds: heartbeat.timestampSeconds, bpm: heartbeat.bpm, source: 'watch'}; + }, + playRegulationProtocol: kind => + haptikaCardiacMirror.playRegulationProtocol(kind), + stopRegulationProtocol: () => + haptikaCardiacMirror.stopRegulationProtocol(), +}; + +export const cardiacMirrorFallbackPatternInfo = { + id: CARDIAC_MIRROR_PATTERN_ID, + version: CARDIAC_MIRROR_PATTERN_VERSION, +} as const; diff --git a/src/features/cardiacMirror/cardiacMirrorTypes.ts b/src/features/cardiacMirror/cardiacMirrorTypes.ts new file mode 100644 index 0000000..9ed989b --- /dev/null +++ b/src/features/cardiacMirror/cardiacMirrorTypes.ts @@ -0,0 +1,31 @@ +export type CardiacMirrorMode = 'realtime' | 'estimation'; + +export interface CardiacMirrorHeartbeat { + timestampSeconds: number; + bpm: number; + source: 'watch'; +} + +export interface CardiacMirrorTarget { + bpm: number; + intervalSeconds: number; + elapsedSeconds: number; +} + +export interface CardiacMirrorPatternInfo { + id: string; + version: string; +} + +export interface CardiacMirrorNative { + isSupported(): boolean; + patternInfo(): CardiacMirrorPatternInfo; + start(targetBpm: number): void; + pulse(): void; + stop(): void; + startWatchSession(): void; + watchReachable(): boolean; + latestHeartbeat(): CardiacMirrorHeartbeat | undefined; + playRegulationProtocol(kind: string): number; + stopRegulationProtocol(): void; +} diff --git a/src/features/cardiacMirror/regulationComparison.test.ts b/src/features/cardiacMirror/regulationComparison.test.ts new file mode 100644 index 0000000..6c21ecc --- /dev/null +++ b/src/features/cardiacMirror/regulationComparison.test.ts @@ -0,0 +1,69 @@ +import type {StoredMeasurement} from '../ppg/storage/storageTypes'; +import {makeProcessingResult} from '../ppg/tests/syntheticMeasurements'; +import {compareRegulationMeasurements} from './regulationComparison'; + +function measurement( + id: string, + measuredAt: string, + bpm: number, + rmssd: number, + stressIndex: number | null, +): StoredMeasurement { + return { + schemaVersion: 1, + acquisition: {cameraPosition: 'front', torchUsed: false}, + id, + measuredAt, + atRest: true, + baselineEligible: true, + cameraResult: makeProcessingResult({ + bpm: {...makeProcessingResult().bpm, bpm}, + prv: { + ...makeProcessingResult().prv, + estimatedRmssdMs: rmssd, + estimatedSdnnMs: rmssd + 6, + }, + }), + potentialStress: { + index: stressIndex, + confidence: 0.8, + status: stressIndex === null ? 'baselineBuilding' : 'personalized', + bpmContribution: null, + prvContribution: null, + signalQuality: 0.9, + baselineMeasurementCount: 10, + healthKitUsedForScore: false, + limitations: [], + }, + healthKitContext: null, + selfReport: null, + }; +} + +describe('regulation before/after comparison', () => { + test('calculates signed absolute and percentage changes', () => { + const before = measurement('before', '2026-07-15T10:00:00.000Z', 90, 30, 70); + const after = measurement('after', '2026-07-15T10:07:00.000Z', 81, 36, 50); + const comparison = compareRegulationMeasurements(before, after); + + expect(comparison.elapsedSeconds).toBe(420); + expect(comparison.bpm).toMatchObject({delta: -9, percentChange: -10}); + expect(comparison.rmssdMs).toMatchObject({delta: 6, percentChange: 20}); + expect(comparison.potentialStressIndex?.delta).toBe(-20); + expect(comparison.limitations).toEqual([]); + }); + + test('does not manufacture PRV or stress comparisons when unavailable', () => { + const before = measurement('before', '2026-07-15T10:00:00.000Z', 90, 30, null); + const after = measurement('after', '2026-07-15T10:07:00.000Z', 88, 32, null); + after.cameraResult.prv.reliable = false; + const comparison = compareRegulationMeasurements(before, after); + + expect(comparison.rmssdMs).toBeNull(); + expect(comparison.sdnnMs).toBeNull(); + expect(comparison.potentialStressIndex).toBeNull(); + expect(comparison.limitations).toEqual( + expect.arrayContaining(['prvUnavailable', 'stressIndexUnavailable']), + ); + }); +}); diff --git a/src/features/cardiacMirror/regulationComparison.ts b/src/features/cardiacMirror/regulationComparison.ts new file mode 100644 index 0000000..f0cb9d1 --- /dev/null +++ b/src/features/cardiacMirror/regulationComparison.ts @@ -0,0 +1,81 @@ +import type {StoredMeasurement} from '../ppg/storage/storageTypes'; +import type { + MetricComparison, + RegulationComparison, + RegulationComparisonLimitation, +} from './regulationSessionTypes'; + +const finite = (value: number | null | undefined): value is number => + value !== null && value !== undefined && Number.isFinite(value); + +function compareMetric(before: number, after: number): MetricComparison { + const delta = after - before; + return { + before, + after, + delta, + percentChange: before === 0 ? null : (delta / Math.abs(before)) * 100, + }; +} + +export function compareRegulationMeasurements( + before: StoredMeasurement, + after: StoredMeasurement, +): RegulationComparison { + const limitations = new Set(); + if (!before.cameraResult.valid) limitations.add('invalidBeforeMeasurement'); + if (!after.cameraResult.valid) limitations.add('invalidAfterMeasurement'); + + const beforeBpm = before.cameraResult.bpm.bpm; + const afterBpm = after.cameraResult.bpm.bpm; + const bpm = finite(beforeBpm) && finite(afterBpm) + ? compareMetric(beforeBpm, afterBpm) + : null; + if (bpm === null) limitations.add('bpmUnavailable'); + + const prvComparable = + before.cameraResult.prv.reliable && after.cameraResult.prv.reliable; + const beforeRmssd = before.cameraResult.prv.estimatedRmssdMs; + const afterRmssd = after.cameraResult.prv.estimatedRmssdMs; + const beforeSdnn = before.cameraResult.prv.estimatedSdnnMs; + const afterSdnn = after.cameraResult.prv.estimatedSdnnMs; + const rmssdMs = prvComparable && finite(beforeRmssd) && finite(afterRmssd) + ? compareMetric(beforeRmssd, afterRmssd) + : null; + const sdnnMs = prvComparable && finite(beforeSdnn) && finite(afterSdnn) + ? compareMetric(beforeSdnn, afterSdnn) + : null; + if (rmssdMs === null || sdnnMs === null) limitations.add('prvUnavailable'); + + const beforeStress = before.potentialStress.index; + const afterStress = after.potentialStress.index; + const potentialStressIndex = finite(beforeStress) && finite(afterStress) + ? compareMetric(beforeStress, afterStress) + : null; + if (potentialStressIndex === null) limitations.add('stressIndexUnavailable'); + + const beforeFeeling = before.selfReport?.feeling; + const afterFeeling = after.selfReport?.feeling; + const feeling = finite(beforeFeeling) && finite(afterFeeling) + ? compareMetric(beforeFeeling, afterFeeling) + : null; + + return { + beforeMeasurementId: before.id, + afterMeasurementId: after.id, + elapsedSeconds: Math.max( + 0, + (Date.parse(after.measuredAt) - Date.parse(before.measuredAt)) / 1_000, + ), + bpm, + rmssdMs, + sdnnMs, + potentialStressIndex, + feeling, + signalQuality: compareMetric( + before.cameraResult.quality.score, + after.cameraResult.quality.score, + ), + limitations: [...limitations], + }; +} diff --git a/src/features/cardiacMirror/regulationManifest.test.ts b/src/features/cardiacMirror/regulationManifest.test.ts new file mode 100644 index 0000000..9960dd8 --- /dev/null +++ b/src/features/cardiacMirror/regulationManifest.test.ts @@ -0,0 +1,26 @@ +import { + HAPTIC_REGULATION_CATALOG_VERSION, + HAPTIC_REGULATION_PROTOCOLS, + getRegulationProtocol, +} from './regulationProtocols'; + +describe('shared regulation protocol manifest', () => { + it('loads the seven unique, versioned protocols', () => { + expect(HAPTIC_REGULATION_CATALOG_VERSION).toBe('1.0.0'); + expect(HAPTIC_REGULATION_PROTOCOLS).toHaveLength(7); + expect(new Set(HAPTIC_REGULATION_PROTOCOLS.map(item => item.kind)).size).toBe(7); + }); + + it('contains finite phases and declared durations', () => { + HAPTIC_REGULATION_PROTOCOLS.forEach(protocol => { + expect(protocol.durationSeconds).toBeGreaterThan(0); + expect(protocol.allowedDurationsSeconds).toContain(protocol.durationSeconds); + expect(protocol.phases.length).toBeGreaterThan(0); + protocol.phases.forEach(phase => { + expect(Number.isFinite(phase.durationSeconds)).toBe(true); + expect(phase.durationSeconds).toBeGreaterThan(0); + }); + expect(getRegulationProtocol(protocol.kind)).toBe(protocol); + }); + }); +}); diff --git a/src/features/cardiacMirror/regulationPhaseEngine.test.ts b/src/features/cardiacMirror/regulationPhaseEngine.test.ts new file mode 100644 index 0000000..3cf2202 --- /dev/null +++ b/src/features/cardiacMirror/regulationPhaseEngine.test.ts @@ -0,0 +1,21 @@ +import {activeRegulationPhase} from './regulationPhaseEngine'; +import {getRegulationProtocol} from './regulationProtocols'; + +describe('regulation phase engine', () => { + test('crosses every box boundary and loops', () => { + const protocol = getRegulationProtocol('box4444'); + expect(activeRegulationPhase(protocol, 0).phase.label).toBe('Inspirez'); + expect(activeRegulationPhase(protocol, 4).phase.label).toBe('Gardez l’air'); + expect(activeRegulationPhase(protocol, 8).phase.label).toBe('Expirez'); + expect(activeRegulationPhase(protocol, 12).phase.label).toBe('Restez vide'); + expect(activeRegulationPhase(protocol, 16)).toMatchObject({cycleIndex: 1}); + }); + + test('counts four complete 4-7-8 cycles', () => { + const protocol = getRegulationProtocol('breathing478'); + expect(activeRegulationPhase(protocol, 3.9).phase.kind).toBe('inhale'); + expect(activeRegulationPhase(protocol, 4).phase.kind).toBe('hold'); + expect(activeRegulationPhase(protocol, 11).phase.kind).toBe('exhale'); + expect(activeRegulationPhase(protocol, 75).cycleIndex).toBe(3); + }); +}); diff --git a/src/features/cardiacMirror/regulationPhaseEngine.ts b/src/features/cardiacMirror/regulationPhaseEngine.ts new file mode 100644 index 0000000..3147579 --- /dev/null +++ b/src/features/cardiacMirror/regulationPhaseEngine.ts @@ -0,0 +1,29 @@ +import type {HapticRegulationProtocol, RegulationPhase} from './regulationProtocols'; + +export interface ActiveRegulationPhase { + phase: RegulationPhase; + remainingSeconds: number; + cycleIndex: number; +} + +export function activeRegulationPhase( + protocol: HapticRegulationProtocol, + elapsedSeconds: number, +): ActiveRegulationPhase { + const cycleDuration = protocol.phases.reduce((sum, phase) => sum + phase.durationSeconds, 0); + const safeElapsed = Math.max(0, elapsedSeconds); + const cyclePosition = cycleDuration <= 0 ? 0 : safeElapsed % cycleDuration; + let boundary = 0; + for (const phase of protocol.phases) { + boundary += phase.durationSeconds; + if (cyclePosition < boundary) { + return { + phase, + remainingSeconds: Math.max(0, Math.ceil(boundary - cyclePosition)), + cycleIndex: cycleDuration <= 0 ? 0 : Math.floor(safeElapsed / cycleDuration), + }; + } + } + const fallback = protocol.phases.at(-1)!; + return {phase: fallback, remainingSeconds: 0, cycleIndex: 0}; +} diff --git a/src/features/cardiacMirror/regulationProtocols.test.ts b/src/features/cardiacMirror/regulationProtocols.test.ts new file mode 100644 index 0000000..667e8b6 --- /dev/null +++ b/src/features/cardiacMirror/regulationProtocols.test.ts @@ -0,0 +1,58 @@ +import { + formatProtocolDuration, + getRegulationProtocol, + HAPTIC_REGULATION_PROTOCOLS, + recommendRegulationProtocol, +} from './regulationProtocols'; + +describe('regulation protocols', () => { + it('publishes the seven protocols grouped into three families', () => { + expect(HAPTIC_REGULATION_PROTOCOLS.map(protocol => protocol.kind)).toEqual([ + 'myCoherence', 'coherence55', 'box4444', 'breathing478', + 'relax46', 'sosStress', 'microAnchor', + ]); + expect(new Set(HAPTIC_REGULATION_PROTOCOLS.map(protocol => protocol.family))).toEqual( + new Set(['breathe', 'ground', 'recenter']), + ); + }); + + it('models box and 4-7-8 with exact phases and durations', () => { + expect(getRegulationProtocol('box4444')).toMatchObject({ + durationSeconds: 240, + phases: [ + {kind: 'inhale', durationSeconds: 4}, + {kind: 'hold', durationSeconds: 4}, + {kind: 'exhale', durationSeconds: 4}, + {kind: 'hold', durationSeconds: 4}, + ], + }); + expect(getRegulationProtocol('breathing478')).toMatchObject({ + durationSeconds: 76, + phases: [ + {kind: 'inhale', durationSeconds: 4}, + {kind: 'hold', durationSeconds: 7}, + {kind: 'exhale', durationSeconds: 8}, + ], + }); + expect(getRegulationProtocol('myCoherence')).toMatchObject({ + mode: 'livePPG', + allowedDurationsSeconds: [180, 300], + intensity: 'aucune', + }); + }); + + it('formats short and long durations', () => { + expect(formatProtocolDuration(37)).toBe('37 sec'); + expect(formatProtocolDuration(180)).toBe('3 min'); + expect(formatProtocolDuration(62)).toBe('1 min 2 sec'); + }); +}); + +describe('regulation recommendation', () => { + it.each([ + [null, 'myCoherence'], [20, 'microAnchor'], [45, 'myCoherence'], + [70, 'relax46'], [90, 'sosStress'], + ] as const)('maps activation %s to %s without a diagnosis', (index, expected) => { + expect(recommendRegulationProtocol(index)).toBe(expected); + }); +}); diff --git a/src/features/cardiacMirror/regulationProtocols.ts b/src/features/cardiacMirror/regulationProtocols.ts new file mode 100644 index 0000000..ff26a56 --- /dev/null +++ b/src/features/cardiacMirror/regulationProtocols.ts @@ -0,0 +1,77 @@ +export type HapticRegulationProtocolKind = + | 'myCoherence' + | 'coherence55' + | 'box4444' + | 'breathing478' + | 'relax46' + | 'sosStress' + | 'microAnchor'; + +export type RegulationFamily = 'breathe' | 'ground' | 'recenter'; +export type RegulationPhaseKind = 'inhale' | 'hold' | 'exhale' | 'anchor'; + +export interface RegulationPhase { + kind: RegulationPhaseKind; + durationSeconds: number; + label: string; +} + +export interface ProtocolEducation { + definition: string; + documentedBenefit: string; + moduleAction: string; + sources: readonly string[]; +} + +export interface HapticRegulationProtocol { + kind: HapticRegulationProtocolKind; + family: RegulationFamily; + mode: 'haptic' | 'livePPG'; + eyebrow: string; + title: string; + description: string; + durationSeconds: number; + allowedDurationsSeconds: readonly number[]; + phases: readonly RegulationPhase[]; + intensity: 'aucune' | 'douce' | 'modérée' | 'présente'; + education: ProtocolEducation; +} + +interface RegulationProtocolManifestV1 { + schemaVersion: 1; + catalogVersion: string; + protocols: HapticRegulationProtocol[]; +} + +// Metro, Jest and the Swift Watch target all consume this same versioned +// catalogue. Keeping the runtime assertion here prevents silently accepting a +// future incompatible manifest. +const protocolManifest = require('../../../shared/regulation-protocols.v1.json') as RegulationProtocolManifestV1; +if (protocolManifest.schemaVersion !== 1) { + throw new Error('Unsupported regulation protocol manifest'); +} + +export const HAPTIC_REGULATION_PROTOCOLS: readonly HapticRegulationProtocol[] = + protocolManifest.protocols; + +export const HAPTIC_REGULATION_CATALOG_VERSION = + protocolManifest.catalogVersion; + +export function getRegulationProtocol(kind: HapticRegulationProtocolKind): HapticRegulationProtocol { + return HAPTIC_REGULATION_PROTOCOLS.find(protocol => protocol.kind === kind) ?? HAPTIC_REGULATION_PROTOCOLS[0]!; +} + +export function formatProtocolDuration(durationSeconds: number): string { + if (durationSeconds < 60) return `${durationSeconds} sec`; + const minutes = Math.floor(durationSeconds / 60); + const seconds = durationSeconds % 60; + return seconds === 0 ? `${minutes} min` : `${minutes} min ${seconds} sec`; +} + +export function recommendRegulationProtocol(potentialActivationIndex: number | null): HapticRegulationProtocolKind { + if (potentialActivationIndex === null || !Number.isFinite(potentialActivationIndex)) return 'myCoherence'; + if (potentialActivationIndex > 80) return 'sosStress'; + if (potentialActivationIndex > 60) return 'relax46'; + if (potentialActivationIndex > 30) return 'myCoherence'; + return 'microAnchor'; +} diff --git a/src/features/cardiacMirror/regulationSessionRepository.test.ts b/src/features/cardiacMirror/regulationSessionRepository.test.ts new file mode 100644 index 0000000..5ccc16f --- /dev/null +++ b/src/features/cardiacMirror/regulationSessionRepository.test.ts @@ -0,0 +1,126 @@ +import {storageKeys} from '../ppg/storage/database'; + +const mockStore = new Map(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { + mockStore.set(key, value); + }), + }, +})); + +import { + parseRegulationSession, + regulationSessionRepository, +} from './regulationSessionRepository'; + +describe('regulation session repository', () => { + beforeEach(() => mockStore.clear()); + + test('persists the full in-progress, awaiting-after and completed lifecycle', async () => { + const created = await regulationSessionRepository.create({ + id: 'session-1', + protocolKind: 'coherence55', + beforeMeasurementId: 'before-1', + startedAt: '2026-07-15T10:00:00.000Z', + }); + expect(created.status).toBe('inProgress'); + + const awaiting = await regulationSessionRepository.markAwaitingAfter( + created.id, + '2026-07-15T10:05:00.000Z', + ); + expect(awaiting.status).toBe('awaitingAfter'); + + const completed = await regulationSessionRepository.complete( + created.id, + 'after-1', + '2026-07-15T10:07:00.000Z', + ); + expect(completed).toMatchObject({ + status: 'completed', + beforeMeasurementId: 'before-1', + afterMeasurementId: 'after-1', + }); + expect(await regulationSessionRepository.getById(created.id)).toEqual(completed); + }); + + test('serializes concurrent creates without losing either session', async () => { + await Promise.all([ + regulationSessionRepository.create({ + id: 'session-a', + protocolKind: 'relax46', + beforeMeasurementId: 'before-a', + startedAt: '2026-07-15T11:00:00.000Z', + }), + regulationSessionRepository.create({ + id: 'session-b', + protocolKind: 'microAnchor', + beforeMeasurementId: 'before-b', + startedAt: '2026-07-15T11:01:00.000Z', + }), + ]); + expect((await regulationSessionRepository.getAll()).map(item => item.id)).toEqual([ + 'session-b', + 'session-a', + ]); + }); + + test('rejects impossible transitions and chronology', async () => { + await regulationSessionRepository.create({ + id: 'session-invalid', + protocolKind: 'sosStress', + beforeMeasurementId: 'before', + startedAt: '2026-07-15T12:00:00.000Z', + }); + await expect( + regulationSessionRepository.complete('session-invalid', 'after'), + ).rejects.toThrow(/await/); + await expect( + regulationSessionRepository.markAwaitingAfter( + 'session-invalid', + '2026-07-15T11:59:00.000Z', + ), + ).rejects.toThrow(/precede/); + }); + + test('migrates a status-less row and drops malformed storage rows', async () => { + const legacy = { + schemaVersion: 1, + id: 'legacy', + protocolKind: 'relax46', + beforeMeasurementId: 'before', + afterMeasurementId: null, + startedAt: '2026-07-15T13:00:00.000Z', + exerciseCompletedAt: '2026-07-15T13:03:00.000Z', + completedAt: null, + updatedAt: '2026-07-15T13:03:00.000Z', + }; + mockStore.set( + storageKeys.regulationSessions, + JSON.stringify([legacy, {...legacy, id: 'broken', protocolKind: 'unknown'}]), + ); + expect(await regulationSessionRepository.getAll()).toEqual([ + {...legacy, schemaVersion: 1, status: 'awaitingAfter'}, + ]); + expect(parseRegulationSession({...legacy, startedAt: 'invalid'})).toBeNull(); + }); + + test('removes sessions linked to a deleted measurement and can clear everything', async () => { + await regulationSessionRepository.create({ + id: 'linked', protocolKind: 'relax46', beforeMeasurementId: 'measurement-1', + startedAt: '2026-07-15T14:00:00.000Z', + }); + await regulationSessionRepository.create({ + id: 'kept', protocolKind: 'relax46', beforeMeasurementId: 'measurement-2', + startedAt: '2026-07-15T14:01:00.000Z', + }); + await regulationSessionRepository.deleteRelatedToMeasurement('measurement-1'); + expect((await regulationSessionRepository.getAll()).map(item => item.id)).toEqual(['kept']); + await regulationSessionRepository.clearAll(); + expect(await regulationSessionRepository.getAll()).toEqual([]); + }); +}); diff --git a/src/features/cardiacMirror/regulationSessionRepository.ts b/src/features/cardiacMirror/regulationSessionRepository.ts new file mode 100644 index 0000000..1804c6a --- /dev/null +++ b/src/features/cardiacMirror/regulationSessionRepository.ts @@ -0,0 +1,263 @@ +import {readJSON, storageKeys, writeJSON} from '../ppg/storage/database'; +import { + HAPTIC_REGULATION_PROTOCOLS, + type HapticRegulationProtocolKind, +} from './regulationProtocols'; +import type { + CreateRegulationSessionInput, + RegulationSession, + RegulationSessionStatus, +} from './regulationSessionTypes'; + +type UnknownRecord = Record; + +const protocolKinds = new Set( + HAPTIC_REGULATION_PROTOCOLS.map(protocol => protocol.kind), +); +const statuses = new Set([ + 'inProgress', + 'awaitingAfter', + 'completed', +]); + +const isRecord = (value: unknown): value is UnknownRecord => + typeof value === 'object' && value !== null && !Array.isArray(value); +const isNonEmptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const isDateString = (value: unknown): value is string => + typeof value === 'string' && Number.isFinite(Date.parse(value)); +const isNullableDateString = (value: unknown): value is string | null => + value === null || isDateString(value); +const isNullableString = (value: unknown): value is string | null => + value === null || isNonEmptyString(value); + +function inferStatus(value: UnknownRecord): RegulationSessionStatus | null { + if (typeof value.status === 'string' && statuses.has(value.status as RegulationSessionStatus)) { + return value.status as RegulationSessionStatus; + } + // Early development rows may not contain a status. Infer it from their + // nullable milestones so they remain readable without weakening validation. + if (isNonEmptyString(value.afterMeasurementId) && isDateString(value.completedAt)) { + return 'completed'; + } + if (isDateString(value.exerciseCompletedAt)) return 'awaitingAfter'; + return value.status === undefined ? 'inProgress' : null; +} + +export function parseRegulationSession(value: unknown): RegulationSession | null { + if (!isRecord(value)) return null; + const status = inferStatus(value); + if ( + (value.schemaVersion !== undefined && value.schemaVersion !== 1) || + !isNonEmptyString(value.id) || + !isNonEmptyString(value.protocolKind) || + !protocolKinds.has(value.protocolKind as HapticRegulationProtocolKind) || + !isNonEmptyString(value.beforeMeasurementId) || + !isNullableString(value.afterMeasurementId) || + status === null || + !isDateString(value.startedAt) || + !isNullableDateString(value.exerciseCompletedAt) || + !isNullableDateString(value.completedAt) || + !isDateString(value.updatedAt) + ) { + return null; + } + + const startedAt = Date.parse(value.startedAt); + const exerciseCompletedAt = + value.exerciseCompletedAt === null ? null : Date.parse(value.exerciseCompletedAt); + const completedAt = value.completedAt === null ? null : Date.parse(value.completedAt); + if ( + (exerciseCompletedAt !== null && exerciseCompletedAt < startedAt) || + (completedAt !== null && + (completedAt < startedAt || + (exerciseCompletedAt !== null && completedAt < exerciseCompletedAt))) || + Date.parse(value.updatedAt) < startedAt + ) { + return null; + } + + const afterMeasurementId = value.afterMeasurementId as string | null; + const validMilestones = + (status === 'inProgress' && + exerciseCompletedAt === null && + completedAt === null && + afterMeasurementId === null) || + (status === 'awaitingAfter' && + exerciseCompletedAt !== null && + completedAt === null && + afterMeasurementId === null) || + (status === 'completed' && + exerciseCompletedAt !== null && + completedAt !== null && + afterMeasurementId !== null && + afterMeasurementId !== value.beforeMeasurementId); + if (!validMilestones) return null; + + return { + schemaVersion: 1, + id: value.id, + protocolKind: value.protocolKind as HapticRegulationProtocolKind, + beforeMeasurementId: value.beforeMeasurementId, + afterMeasurementId, + status, + startedAt: value.startedAt, + exerciseCompletedAt: value.exerciseCompletedAt as string | null, + completedAt: value.completedAt as string | null, + updatedAt: value.updatedAt, + }; +} + +let mutationQueue: Promise = Promise.resolve(); + +async function readAllFromStorage(): Promise { + const raw = await readJSON(storageKeys.regulationSessions, []); + if (!Array.isArray(raw)) return []; + const unique = new Map(); + raw.forEach(value => { + const parsed = parseRegulationSession(value); + if (parsed !== null && !unique.has(parsed.id)) unique.set(parsed.id, parsed); + }); + return [...unique.values()].sort( + (left, right) => Date.parse(right.startedAt) - Date.parse(left.startedAt), + ); +} + +function enqueueMutation(operation: () => Promise): Promise { + const pending = mutationQueue.then(operation); + mutationQueue = pending.then(() => undefined, () => undefined); + return pending; +} + +function createId(): string { + return `regulation-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} + +function validInputDate(value: string, field: string): void { + if (!isDateString(value)) throw new Error(`${field} must be a valid date`); +} + +export const regulationSessionRepository = { + async getAll(): Promise { + await mutationQueue; + return readAllFromStorage(); + }, + + async getById(id: string): Promise { + const sessions = await this.getAll(); + return sessions.find(session => session.id === id) ?? null; + }, + + async create(input: CreateRegulationSessionInput): Promise { + return enqueueMutation(async () => { + if (!protocolKinds.has(input.protocolKind)) throw new Error('Unknown protocol kind'); + if (!isNonEmptyString(input.beforeMeasurementId)) { + throw new Error('beforeMeasurementId is required'); + } + const id = input.id ?? createId(); + if (!isNonEmptyString(id)) throw new Error('id is required'); + const startedAt = input.startedAt ?? new Date().toISOString(); + validInputDate(startedAt, 'startedAt'); + const current = await readAllFromStorage(); + if (current.some(session => session.id === id)) { + throw new Error(`Regulation session ${id} already exists`); + } + const session: RegulationSession = { + schemaVersion: 1, + id, + protocolKind: input.protocolKind, + beforeMeasurementId: input.beforeMeasurementId, + afterMeasurementId: null, + status: 'inProgress', + startedAt, + exerciseCompletedAt: null, + completedAt: null, + updatedAt: startedAt, + }; + await writeJSON(storageKeys.regulationSessions, [session, ...current]); + return session; + }); + }, + + async markAwaitingAfter( + id: string, + exerciseCompletedAt = new Date().toISOString(), + ): Promise { + return enqueueMutation(async () => { + validInputDate(exerciseCompletedAt, 'exerciseCompletedAt'); + const current = await readAllFromStorage(); + const existing = current.find(session => session.id === id); + if (existing === undefined) throw new Error(`Regulation session ${id} not found`); + if (existing.status === 'completed') throw new Error('Completed session cannot be reopened'); + if (Date.parse(exerciseCompletedAt) < Date.parse(existing.startedAt)) { + throw new Error('exerciseCompletedAt cannot precede startedAt'); + } + const updated: RegulationSession = { + ...existing, + status: 'awaitingAfter', + exerciseCompletedAt, + updatedAt: exerciseCompletedAt, + }; + await writeJSON( + storageKeys.regulationSessions, + current.map(session => (session.id === id ? updated : session)), + ); + return updated; + }); + }, + + async complete( + id: string, + afterMeasurementId: string, + completedAt = new Date().toISOString(), + ): Promise { + return enqueueMutation(async () => { + if (!isNonEmptyString(afterMeasurementId)) throw new Error('afterMeasurementId is required'); + validInputDate(completedAt, 'completedAt'); + const current = await readAllFromStorage(); + const existing = current.find(session => session.id === id); + if (existing === undefined) throw new Error(`Regulation session ${id} not found`); + if (existing.status !== 'awaitingAfter' || existing.exerciseCompletedAt === null) { + throw new Error('Session must await an after measurement before completion'); + } + if (afterMeasurementId === existing.beforeMeasurementId) { + throw new Error('Before and after measurements must be different'); + } + if (Date.parse(completedAt) < Date.parse(existing.exerciseCompletedAt)) { + throw new Error('completedAt cannot precede exerciseCompletedAt'); + } + const updated: RegulationSession = { + ...existing, + afterMeasurementId, + status: 'completed', + completedAt, + updatedAt: completedAt, + }; + await writeJSON( + storageKeys.regulationSessions, + current.map(session => (session.id === id ? updated : session)), + ); + return updated; + }); + }, + + async deleteRelatedToMeasurement(measurementId: string): Promise { + return enqueueMutation(async () => { + const current = await readAllFromStorage(); + await writeJSON( + storageKeys.regulationSessions, + current.filter( + session => + session.beforeMeasurementId !== measurementId && + session.afterMeasurementId !== measurementId, + ), + ); + }); + }, + + async clearAll(): Promise { + return enqueueMutation(async () => { + await writeJSON(storageKeys.regulationSessions, []); + }); + }, +}; diff --git a/src/features/cardiacMirror/regulationSessionTypes.ts b/src/features/cardiacMirror/regulationSessionTypes.ts new file mode 100644 index 0000000..2e39985 --- /dev/null +++ b/src/features/cardiacMirror/regulationSessionTypes.ts @@ -0,0 +1,53 @@ +import type {HapticRegulationProtocolKind} from './regulationProtocols'; + +export type RegulationSessionStatus = + | 'inProgress' + | 'awaitingAfter' + | 'completed'; + +export interface RegulationSession { + schemaVersion: 1; + id: string; + protocolKind: HapticRegulationProtocolKind; + beforeMeasurementId: string; + afterMeasurementId: string | null; + status: RegulationSessionStatus; + startedAt: string; + exerciseCompletedAt: string | null; + completedAt: string | null; + updatedAt: string; +} + +export interface CreateRegulationSessionInput { + id?: string; + protocolKind: HapticRegulationProtocolKind; + beforeMeasurementId: string; + startedAt?: string; +} + +export type RegulationComparisonLimitation = + | 'invalidBeforeMeasurement' + | 'invalidAfterMeasurement' + | 'bpmUnavailable' + | 'prvUnavailable' + | 'stressIndexUnavailable'; + +export interface MetricComparison { + before: number; + after: number; + delta: number; + percentChange: number | null; +} + +export interface RegulationComparison { + beforeMeasurementId: string; + afterMeasurementId: string; + elapsedSeconds: number; + bpm: MetricComparison | null; + rmssdMs: MetricComparison | null; + sdnnMs: MetricComparison | null; + potentialStressIndex: MetricComparison | null; + feeling: MetricComparison | null; + signalQuality: MetricComparison; + limitations: readonly RegulationComparisonLimitation[]; +} diff --git a/src/features/coherence/MyCoherenceScreen.tsx b/src/features/coherence/MyCoherenceScreen.tsx new file mode 100644 index 0000000..aefb07e --- /dev/null +++ b/src/features/coherence/MyCoherenceScreen.tsx @@ -0,0 +1,318 @@ +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {AppState, Pressable, StyleSheet, Text, View} from 'react-native'; +import Svg, {Path} from 'react-native-svg'; +import {haptikaPPG, type PPGFrameUpdate} from 'react-native-haptika-ppg'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {colors, radii, spacing} from '../../theme'; +import {PPGCameraView} from '../ppg/camera/PPGCameraView'; +import type {PPGCameraError} from '../ppg/camera/cameraTypes'; +import {PrimaryButton} from '../ppg/components/PrimaryButton'; +import { + calculateCoherenceScore, + coherenceScoreLabel, + smoothCoherenceScore, +} from './coherenceEngine'; +import {coherenceSessionRepository} from './coherenceSessionRepository'; +import type { + CoherenceBeat, + CoherenceScoreSnapshot, + CoherenceSessionDurationSeconds, + StoredCoherenceSession, +} from './coherenceTypes'; + +export interface MyCoherenceSummary { + startedAt: string; + completedAt: string; + elapsedSeconds: number; + completed: boolean; +} + +interface MyCoherenceScreenProps { + cameraPosition: 'front' | 'back'; + initialDurationSeconds?: CoherenceSessionDurationSeconds; + beforeMeasurementId?: string | null; + regulationSessionId?: string | null; + onCompleted: (summary: MyCoherenceSummary, session: StoredCoherenceSession) => void; + onCancelled: (summary: MyCoherenceSummary, session: StoredCoherenceSession | null) => void; +} + +function waveformPath(values: readonly number[], width: number, height: number): string { + if (values.length < 2) return ''; + const finiteValues = values.filter(Number.isFinite); + const minimum = Math.min(...finiteValues); + const maximum = Math.max(...finiteValues); + const range = Math.max(maximum - minimum, 0.00001); + return values.map((value, index) => { + const x = (index / (values.length - 1)) * width; + const y = height - ((value - minimum) / range) * height; + return `${index === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); +} + +export function MyCoherenceScreen({ + cameraPosition, + initialDurationSeconds = 180, + beforeMeasurementId = null, + regulationSessionId = null, + onCompleted, + onCancelled, +}: MyCoherenceScreenProps): React.JSX.Element { + const insets = useSafeAreaInsets(); + const [durationSeconds, setDurationSeconds] = useState(initialDurationSeconds); + const [running, setRunning] = useState(false); + const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [latestFrame, setLatestFrame] = useState(null); + const [scoreSnapshot, setScoreSnapshot] = useState(null); + const [waveform, setWaveform] = useState([]); + const [error, setError] = useState(null); + const startFrameTimeRef = useRef(null); + const startedAtRef = useRef(''); + const beatsRef = useRef([]); + const timelineRef = useRef([]); + const lastScoreAtRef = useRef(-Infinity); + const smoothedScoreRef = useRef(null); + const terminalRef = useRef(false); + + const stopNative = useCallback(() => { + try { haptikaPPG.cancelMeasurement(); } catch { /* already stopped */ } + setRunning(false); + }, []); + + const persistAndLeave = useCallback(async (completed: boolean): Promise => { + if (terminalRef.current) return; + terminalRef.current = true; + stopNative(); + const completedAt = new Date().toISOString(); + const summary: MyCoherenceSummary = { + startedAt: startedAtRef.current || completedAt, + completedAt, + elapsedSeconds, + completed, + }; + let stored: StoredCoherenceSession | null = null; + try { + stored = await coherenceSessionRepository.save({ + regulationSessionId, + beforeMeasurementId, + cameraPosition, + plannedDurationSeconds: durationSeconds, + actualDurationSeconds: elapsedSeconds, + startedAt: summary.startedAt, + completedAt, + completed, + timeline: timelineRef.current, + }); + } catch { + // A local persistence failure must not keep the camera active. + } + if (completed && stored !== null) onCompleted(summary, stored); + else onCancelled(summary, stored); + }, [beforeMeasurementId, cameraPosition, durationSeconds, elapsedSeconds, onCancelled, onCompleted, regulationSessionId, stopNative]); + + useEffect(() => { + const subscription = AppState.addEventListener('change', next => { + if (running && next !== 'active') persistAndLeave(false); + }); + return () => subscription.remove(); + }, [persistAndLeave, running]); + + useEffect(() => stopNative, [stopNative]); + + const start = useCallback(() => { + terminalRef.current = false; + startFrameTimeRef.current = null; + startedAtRef.current = new Date().toISOString(); + beatsRef.current = []; + timelineRef.current = []; + lastScoreAtRef.current = -Infinity; + smoothedScoreRef.current = null; + setElapsedSeconds(0); + setScoreSnapshot(null); + setWaveform([]); + setError(null); + try { + haptikaPPG.startMeasurement({ + sessionPurpose: 'coherence', + targetSampleRate: 30, + minimumFrequencyHz: 0.7, + maximumFrequencyHz: 3.5, + stabilizationSeconds: 0, + measurementSeconds: durationSeconds, + emitEveryFrames: 3, + minimumContactConfidence: 0.48, + }); + setRunning(true); + } catch (caught) { + setError(caught instanceof Error ? caught.message : 'Impossible de démarrer la caméra.'); + } + }, [durationSeconds]); + + const onFrameUpdate = useCallback((update: PPGFrameUpdate): void => { + if (!running || terminalRef.current) return; + setLatestFrame(update); + const sessionStart = startFrameTimeRef.current ?? update.timestampSeconds; + startFrameTimeRef.current = sessionStart; + const elapsed = Math.max(0, update.timestampSeconds - sessionStart); + setElapsedSeconds(elapsed); + setWaveform(current => [...current.slice(-89), update.waveformValue]); + update.acceptedBeats.forEach(beat => { + if (beat.confidence >= 0.35 && Number.isFinite(beat.intervalMs)) { + beatsRef.current.push({ + timestampSeconds: beat.timestampSeconds, + intervalMs: beat.intervalMs, + confidence: beat.confidence, + }); + } + }); + const quality = Math.min( + 1, + Math.max(0, update.liveWindowQuality ?? update.contactConfidence * (1 - update.motionArtifactScore)), + ); + if (elapsed - lastScoreAtRef.current >= 2) { + lastScoreAtRef.current = elapsed; + const calculated = calculateCoherenceScore({ + beats: beatsRef.current, + nowSeconds: update.timestampSeconds, + sessionStartedAtSeconds: sessionStart, + quality, + }); + const smoothed = smoothCoherenceScore(smoothedScoreRef.current, calculated.score); + smoothedScoreRef.current = smoothed; + const point = {...calculated, score: smoothed}; + timelineRef.current.push(point); + setScoreSnapshot(point); + } + if (update.thermalState === 'serious' || update.thermalState === 'critical') { + setError('L’iPhone a trop chauffé. La séance est arrêtée pour votre confort.'); + persistAndLeave(false); + return; + } + if (elapsed >= durationSeconds) persistAndLeave(true); + }, [durationSeconds, persistAndLeave, running]); + + const handleCameraError = useCallback((cameraError: PPGCameraError): void => { + setError(cameraError.message); + persistAndLeave(false); + }, [persistAndLeave]); + + const breathingPosition = elapsedSeconds % 10; + const inhaling = breathingPosition < 5; + const phaseProgress = inhaling ? breathingPosition / 5 : (breathingPosition - 5) / 5; + const orbScale = inhaling ? 0.72 + 0.28 * phaseProgress : 1 - 0.28 * phaseProgress; + const progress = Math.min(1, elapsedSeconds / durationSeconds); + const displayedScore = scoreSnapshot?.score ?? null; + const path = useMemo(() => waveformPath(waveform, 320, 74), [waveform]); + + if (!running) { + return ( + + BIOFEEDBACK RESPIRATOIRE + Ma Cohérence + + La caméra estime votre pouls pendant qu’un rythme 5–5 guide votre respiration. Aucune image n’est enregistrée. + + + DURÉE DE LA SÉANCE + + {([180, 300] as const).map(value => ( + setDurationSeconds(value)} style={[styles.segment, durationSeconds === value && styles.segmentSelected]}> + {value / 60} min + + ))} + + + Pendant la séance, Haptika ne produit aucune vibration afin de ne pas perturber le signal caméra. Le score est expérimental et n’est pas une mesure médicale. + {error !== null ? {error} : null} + + onCancelled({startedAt: '', completedAt: new Date().toISOString(), elapsedSeconds: 0, completed: false}, null)} /> + + ); + } + + return ( + + undefined} + onFrameUpdate={onFrameUpdate} + onInterrupted={() => persistAndLeave(false)} + onError={handleCameraError} + /> + + + + MA COHÉRENCE · DIRECT{Math.max(0, Math.ceil(durationSeconds - elapsedSeconds))} s + = 0.65 ? colors.good : colors.medium}]} />Signal {Math.round((scoreSnapshot?.quality ?? 0) * 100)} % + + + + + + + + {inhaling ? 'Inspirez' : 'Expirez'} + {Math.ceil(5 - (breathingPosition % 5))} + + + + + SYNCHRONISATION{displayedScore === null ? '—' : displayedScore} + {coherenceScoreLabel(displayedScore)}{scoreSnapshot?.bpm ?? '—'} BPM + + + + + Variation optique indicative · pas un tracé ECG + + + + {scoreSnapshot?.status === 'poorSignal' ? 'Signal momentanément insuffisant — la séance continue.' : scoreSnapshot?.status === 'calibrating' ? 'Calibration en cours · premier score après environ 60 secondes.' : 'Synchronisation avec le rythme respiratoire guidé.'} + persistAndLeave(false)} /> + + + ); +} + +const styles = StyleSheet.create({ + root: {flex: 1, backgroundColor: colors.background, paddingHorizontal: spacing.lg, gap: spacing.lg}, + canvas: {position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: colors.background}, + eyebrow: {color: colors.accent, fontSize: 10, fontWeight: '900', letterSpacing: 1.8}, + title: {color: colors.text, fontSize: 42, lineHeight: 48, fontWeight: '900'}, + subtitle: {color: colors.muted, fontSize: 16, lineHeight: 24}, + durationCard: {padding: spacing.md, gap: spacing.md, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface}, + cardLabel: {color: colors.muted, fontSize: 10, fontWeight: '900', letterSpacing: 1.3}, + segmented: {flexDirection: 'row', padding: 4, borderRadius: radii.md, backgroundColor: colors.background}, + segment: {flex: 1, minHeight: 46, alignItems: 'center', justifyContent: 'center', borderRadius: radii.sm}, + segmentSelected: {backgroundColor: colors.accent}, + segmentText: {color: colors.muted, fontWeight: '800'}, + segmentTextSelected: {color: colors.background}, + notice: {padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surfaceElevated}, + noticeText: {color: colors.muted, fontSize: 13, lineHeight: 20}, + error: {color: colors.poor, fontSize: 14, lineHeight: 20}, + sessionContent: {flex: 1, justifyContent: 'space-between', gap: spacing.sm}, + sessionHeader: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start'}, + time: {color: colors.text, fontSize: 17, fontWeight: '800', marginTop: 4, fontVariant: ['tabular-nums']}, + qualityPill: {flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.sm, minHeight: 34, borderRadius: radii.pill, borderWidth: 1, borderColor: colors.border}, + qualityDot: {width: 7, height: 7, borderRadius: 4}, + qualityText: {color: colors.muted, fontSize: 12, fontVariant: ['tabular-nums']}, + orbStage: {height: 260, alignItems: 'center', justifyContent: 'center'}, + orbOuter: {width: 230, height: 230, borderRadius: 115, alignItems: 'center', justifyContent: 'center', borderWidth: 1, borderColor: colors.accent, backgroundColor: '#072443'}, + orbInner: {width: 130, height: 130, borderRadius: 65, backgroundColor: colors.accentSoft, borderWidth: 2, borderColor: '#B8EEFF'}, + orbCopy: {position: 'absolute', alignItems: 'center'}, + phase: {color: colors.text, fontSize: 23, fontWeight: '800'}, + phaseCount: {color: colors.accent, fontSize: 36, fontWeight: '300', fontVariant: ['tabular-nums']}, + scoreRow: {flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between', paddingHorizontal: spacing.sm}, + score: {color: colors.text, fontSize: 62, lineHeight: 66, fontWeight: '900', fontVariant: ['tabular-nums']}, + scoreCopy: {alignItems: 'flex-end', paddingBottom: 8, maxWidth: '62%'}, + scoreLabel: {color: colors.accent, fontSize: 15, lineHeight: 20, fontWeight: '800', textAlign: 'right'}, + bpm: {color: colors.muted, marginTop: 4, fontSize: 13, fontVariant: ['tabular-nums']}, + waveCard: {height: 112, paddingHorizontal: spacing.sm, paddingTop: spacing.sm, borderRadius: radii.md, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface}, + waveCaption: {color: colors.muted, fontSize: 10, textAlign: 'center'}, + progressTrack: {height: 5, borderRadius: 3, backgroundColor: colors.surfaceElevated, overflow: 'hidden'}, + progressFill: {height: 5, borderRadius: 3, backgroundColor: colors.accent}, + sessionHint: {color: colors.muted, fontSize: 12, lineHeight: 17, textAlign: 'center'}, +}); diff --git a/src/features/coherence/coherenceEngine.test.ts b/src/features/coherence/coherenceEngine.test.ts new file mode 100644 index 0000000..ef1a704 --- /dev/null +++ b/src/features/coherence/coherenceEngine.test.ts @@ -0,0 +1,63 @@ +import { + calculateCoherenceScore, + coherenceScoreLabel, + smoothCoherenceScore, +} from './coherenceEngine'; +import type {CoherenceBeat} from './coherenceTypes'; + +function resonantBeats(duration = 70, frequency = 0.1): CoherenceBeat[] { + const beats: CoherenceBeat[] = []; + let timestamp = 0; + while (timestamp < duration) { + const intervalMs = 800 + 105 * Math.sin(2 * Math.PI * frequency * timestamp); + timestamp += intervalMs / 1_000; + beats.push({timestampSeconds: timestamp, intervalMs, confidence: 0.95}); + } + return beats; +} + +describe('experimental coherence engine', () => { + test('scores a clean 0.1 Hz modulation and keeps every output finite', () => { + const result = calculateCoherenceScore({ + beats: resonantBeats(), nowSeconds: 70, sessionStartedAtSeconds: 0, quality: 0.9, + }); + expect(result.status).toBe('scored'); + expect(result.score).not.toBeNull(); + expect(result.score!).toBeGreaterThan(55); + expect(result.score!).toBeLessThanOrEqual(100); + expect(Object.values(result.components!)).toEqual( + expect.arrayContaining([expect.any(Number)]), + ); + Object.values(result.components!).forEach(value => expect(Number.isFinite(value)).toBe(true)); + }); + + test('does not publish before calibration or during poor signal', () => { + expect(calculateCoherenceScore({ + beats: resonantBeats(40), nowSeconds: 40, sessionStartedAtSeconds: 0, quality: 0.9, + })).toMatchObject({score: null, status: 'calibrating'}); + expect(calculateCoherenceScore({ + beats: resonantBeats(), nowSeconds: 70, sessionStartedAtSeconds: 0, quality: 0.64, + })).toMatchObject({score: null, status: 'poorSignal'}); + }); + + test('rejects invalid and low-confidence intervals without producing NaN', () => { + const beats = resonantBeats(); + beats.push( + {timestampSeconds: 68, intervalMs: Number.NaN, confidence: 1}, + {timestampSeconds: 69, intervalMs: 2_500, confidence: 1}, + {timestampSeconds: 69.5, intervalMs: 800, confidence: 0.1}, + ); + const result = calculateCoherenceScore({beats, nowSeconds: 70, sessionStartedAtSeconds: 0, quality: 0.9}); + expect(result.score === null || Number.isFinite(result.score)).toBe(true); + expect(Number.isFinite(result.coverage)).toBe(true); + }); + + test('smooths with the versioned EMA and labels exact boundaries', () => { + expect(smoothCoherenceScore(null, 80)).toBe(80); + expect(smoothCoherenceScore(80, 40)).toBe(70); + expect(coherenceScoreLabel(39)).toContain('construction'); + expect(coherenceScoreLabel(40)).toContain('progression'); + expect(coherenceScoreLabel(69)).toContain('progression'); + expect(coherenceScoreLabel(70)).toContain('stable'); + }); +}); diff --git a/src/features/coherence/coherenceEngine.ts b/src/features/coherence/coherenceEngine.ts new file mode 100644 index 0000000..b52ca9e --- /dev/null +++ b/src/features/coherence/coherenceEngine.ts @@ -0,0 +1,229 @@ +import type { + CoherenceBeat, + CoherenceScoreSnapshot, +} from './coherenceTypes'; + +const WINDOW_SECONDS = 60; +const SAMPLE_RATE_HZ = 4; +const MINIMUM_INTERVALS = 35; + +const clamp01 = (value: number): number => Math.min(1, Math.max(0, value)); + +function median(values: readonly number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1]! + sorted[middle]!) / 2 + : sorted[middle]!; +} + +function filterIntervals(beats: readonly CoherenceBeat[]): CoherenceBeat[] { + return beats.filter((beat, index, source) => { + if ( + !Number.isFinite(beat.timestampSeconds) || + !Number.isFinite(beat.intervalMs) || + beat.intervalMs < 300 || + beat.intervalMs > 2_000 || + !Number.isFinite(beat.confidence) || + beat.confidence < 0.35 + ) { + return false; + } + const neighborhood = source + .slice(Math.max(0, index - 3), Math.min(source.length, index + 4)) + .map(candidate => candidate.intervalMs) + .filter(value => value >= 300 && value <= 2_000); + const center = median(neighborhood); + const deviations = neighborhood.map(value => Math.abs(value - center)); + const mad = median(deviations); + const robustLimit = Math.max(120, 3 * 1.4826 * mad); + return Math.abs(beat.intervalMs - center) <= robustLimit; + }); +} + +function resample( + beats: readonly CoherenceBeat[], + start: number, + end: number, +): {times: number[]; values: number[]} { + const times: number[] = []; + const values: number[] = []; + let cursor = 0; + const step = 1 / SAMPLE_RATE_HZ; + for (let time = start; time <= end + 1e-6; time += step) { + while ( + cursor + 1 < beats.length && + beats[cursor + 1]!.timestampSeconds < time + ) { + cursor += 1; + } + const left = beats[cursor]; + const right = beats[Math.min(cursor + 1, beats.length - 1)]; + if (left === undefined || right === undefined) continue; + const gap = right.timestampSeconds - left.timestampSeconds; + if (gap <= 0 || gap > 2) continue; + const ratio = clamp01((time - left.timestampSeconds) / gap); + times.push(time); + values.push(left.intervalMs + (right.intervalMs - left.intervalMs) * ratio); + } + return {times, values}; +} + +function detrend(values: readonly number[]): number[] { + if (values.length < 2) return [...values]; + const n = values.length; + const meanX = (n - 1) / 2; + const meanY = values.reduce((sum, value) => sum + value, 0) / n; + let numerator = 0; + let denominator = 0; + values.forEach((value, index) => { + numerator += (index - meanX) * (value - meanY); + denominator += (index - meanX) ** 2; + }); + const slope = denominator === 0 ? 0 : numerator / denominator; + return values.map((value, index) => value - (meanY + slope * (index - meanX))); +} + +function spectralPower(values: readonly number[], frequencyHz: number): number { + if (values.length < 2) return 0; + let real = 0; + let imaginary = 0; + values.forEach((value, index) => { + const hann = 0.5 * (1 - Math.cos((2 * Math.PI * index) / (values.length - 1))); + const angle = (2 * Math.PI * frequencyHz * index) / SAMPLE_RATE_HZ; + real += value * hann * Math.cos(angle); + imaginary -= value * hann * Math.sin(angle); + }); + return real * real + imaginary * imaginary; +} + +function correlation(left: readonly number[], right: readonly number[]): number { + const count = Math.min(left.length, right.length); + if (count < 8) return 0; + const leftMean = left.slice(0, count).reduce((sum, value) => sum + value, 0) / count; + const rightMean = right.slice(0, count).reduce((sum, value) => sum + value, 0) / count; + let numerator = 0; + let leftPower = 0; + let rightPower = 0; + for (let index = 0; index < count; index += 1) { + const l = left[index]! - leftMean; + const r = right[index]! - rightMean; + numerator += l * r; + leftPower += l * l; + rightPower += r * r; + } + const denominator = Math.sqrt(leftPower * rightPower); + return denominator === 0 ? 0 : numerator / denominator; +} + +function coefficientOfVariation(values: readonly number[]): number { + if (values.length < 2) return 1; + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + if (mean <= 0) return 1; + const variance = + values.reduce((sum, value) => sum + (value - mean) ** 2, 0) / + values.length; + return Math.sqrt(variance) / mean; +} + +export interface CalculateCoherenceScoreInput { + beats: readonly CoherenceBeat[]; + nowSeconds: number; + sessionStartedAtSeconds: number; + quality: number; +} + +/** Experimental PRV-biofeedback score. It never represents a medical measure. */ +export function calculateCoherenceScore({ + beats, + nowSeconds, + sessionStartedAtSeconds, + quality, +}: CalculateCoherenceScoreInput): CoherenceScoreSnapshot { + const windowStart = Math.max(sessionStartedAtSeconds, nowSeconds - WINDOW_SECONDS); + const filtered = filterIntervals( + beats.filter(beat => beat.timestampSeconds >= windowStart && beat.timestampSeconds <= nowSeconds), + ); + const elapsed = nowSeconds - sessionStartedAtSeconds; + const expectedWindow = Math.min(WINDOW_SECONDS, Math.max(0, elapsed)); + const first = filtered[0]?.timestampSeconds ?? nowSeconds; + const last = filtered.at(-1)?.timestampSeconds ?? first; + const coverage = expectedWindow <= 0 ? 0 : clamp01((last - first) / expectedWindow); + const recentIpi = filtered.slice(-8).map(beat => beat.intervalMs); + const bpm = recentIpi.length === 0 ? null : 60_000 / median(recentIpi); + + const unavailable = (status: 'calibrating' | 'poorSignal'): CoherenceScoreSnapshot => ({ + timestampSeconds: nowSeconds - sessionStartedAtSeconds, + score: null, + quality: clamp01(quality), + bpm: bpm === null ? null : Math.round(bpm), + status, + components: null, + validIntervalCount: filtered.length, + coverage, + }); + if (quality < 0.65) return unavailable('poorSignal'); + if (elapsed < WINDOW_SECONDS || filtered.length < MINIMUM_INTERVALS || coverage < 0.75) { + return unavailable('calibrating'); + } + + const sampled = resample(filtered, nowSeconds - WINDOW_SECONDS, nowSeconds); + if (sampled.values.length < WINDOW_SECONDS * SAMPLE_RATE_HZ * 0.75) { + return unavailable('poorSignal'); + } + const signal = detrend(sampled.values); + let totalPower = 0; + let resonancePower = 0; + for (let frequency = 0.04; frequency <= 0.4 + 1e-8; frequency += 0.005) { + const power = spectralPower(signal, frequency); + totalPower += power; + if (frequency >= 0.075 && frequency <= 0.125) resonancePower += power; + } + const spectralRatio = totalPower <= 0 ? 0 : resonancePower / totalPower; + const spectral = clamp01((spectralRatio - 0.2) / 0.6); + + let bestCorrelation = 0; + for (let lag = 0; lag <= 4 * SAMPLE_RATE_HZ; lag += 1) { + const guide = sampled.times.map( + time => Math.sin(2 * Math.PI * 0.1 * (time - sessionStartedAtSeconds - lag / SAMPLE_RATE_HZ)), + ); + bestCorrelation = Math.max(bestCorrelation, correlation(signal, guide)); + } + const phase = clamp01((bestCorrelation - 0.2) / 0.7); + + const amplitudes: number[] = []; + const samplesPerCycle = 10 * SAMPLE_RATE_HZ; + for (let offset = 0; offset + samplesPerCycle <= signal.length; offset += samplesPerCycle) { + const cycle = signal.slice(offset, offset + samplesPerCycle); + amplitudes.push(Math.max(...cycle) - Math.min(...cycle)); + } + const regularity = clamp01(1 - coefficientOfVariation(amplitudes) / 0.35); + const score = Math.round(100 * (0.5 * spectral + 0.35 * phase + 0.15 * regularity)); + return { + timestampSeconds: nowSeconds - sessionStartedAtSeconds, + score, + quality: clamp01(quality), + bpm: bpm === null ? null : Math.round(bpm), + status: 'scored', + components: {spectral, phase, regularity}, + validIntervalCount: filtered.length, + coverage, + }; +} + +export function smoothCoherenceScore( + previous: number | null, + next: number | null, +): number | null { + if (next === null) return null; + if (previous === null) return next; + return Math.round(previous * 0.75 + next * 0.25); +} + +export function coherenceScoreLabel(score: number | null): string { + if (score === null) return 'Synchronisation en construction'; + if (score < 40) return 'Synchronisation en construction'; + if (score < 70) return 'Synchronisation en progression'; + return 'Synchronisation stable'; +} diff --git a/src/features/coherence/coherenceSessionRepository.test.ts b/src/features/coherence/coherenceSessionRepository.test.ts new file mode 100644 index 0000000..3eb7298 --- /dev/null +++ b/src/features/coherence/coherenceSessionRepository.test.ts @@ -0,0 +1,36 @@ +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: {getItem: jest.fn(async () => null), setItem: jest.fn(async () => undefined)}, +})); + +import {__coherenceRepositoryTesting} from './coherenceSessionRepository'; +import type {CoherenceScoreSnapshot} from './coherenceTypes'; + +const point = (timestampSeconds: number, score: number | null): CoherenceScoreSnapshot => ({ + timestampSeconds, + score, + quality: 0.8, + bpm: 72 + timestampSeconds / 10, + status: score === null ? 'calibrating' : 'scored', + components: score === null ? null : {spectral: 0.5, phase: 0.6, regularity: 0.7}, + validIntervalCount: 50, + coverage: 0.9, +}); + +describe('coherence session repository summary', () => { + test('compacts to one point every two seconds and calculates summaries', () => { + const record = __coherenceRepositoryTesting.createRecord({ + cameraPosition: 'front', plannedDurationSeconds: 180, actualDurationSeconds: 65, + startedAt: '2026-07-20T10:00:00.000Z', completedAt: '2026-07-20T10:01:05.000Z', + completed: true, + timeline: [point(0, null), point(1, null), point(60, 50), point(61, 60), point(63, 80), point(65, 70), point(66, 90)], + }); + expect(record.timeline.map(item => item.timestampSeconds)).toEqual([0, 60, 62, 64, 66]); + expect(record.meanScore).toBe(75); + expect(record.bestScore).toBe(90); + expect(record.finalScore).toBe(75); + expect(record.validScoreSeconds).toBe(8); + expect(record.cameraPosition).toBe('front'); + expect(JSON.stringify(record)).not.toMatch(/image|frame/i); + }); +}); diff --git a/src/features/coherence/coherenceSessionRepository.ts b/src/features/coherence/coherenceSessionRepository.ts new file mode 100644 index 0000000..d723098 --- /dev/null +++ b/src/features/coherence/coherenceSessionRepository.ts @@ -0,0 +1,140 @@ +import {readJSON, storageKeys, writeJSON} from '../ppg/storage/database'; +import { + COHERENCE_ALGORITHM_VERSION, + type CoherenceScoreSnapshot, + type StoredCoherenceSession, +} from './coherenceTypes'; + +export interface SaveCoherenceSessionInput { + regulationSessionId?: string | null; + beforeMeasurementId?: string | null; + afterMeasurementId?: string | null; + cameraPosition: 'front' | 'back'; + plannedDurationSeconds: 180 | 300; + actualDurationSeconds: number; + startedAt: string; + completedAt: string; + completed: boolean; + timeline: readonly CoherenceScoreSnapshot[]; +} + +const finite = (value: number): boolean => Number.isFinite(value); +const mean = (values: readonly number[]): number | null => + values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length; +const median = (values: readonly number[]): number | null => { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!; +}; + +function compactTimeline(timeline: readonly CoherenceScoreSnapshot[]): CoherenceScoreSnapshot[] { + const buckets = new Map(); + timeline.forEach(point => { + if (!finite(point.timestampSeconds) || !finite(point.quality)) return; + const bucket = Math.max(0, Math.floor(point.timestampSeconds / 2) * 2); + buckets.set(bucket, {...point, timestampSeconds: bucket}); + }); + return [...buckets.values()].sort((a, b) => a.timestampSeconds - b.timestampSeconds); +} + +function createRecord(input: SaveCoherenceSessionInput): StoredCoherenceSession { + const timeline = compactTimeline(input.timeline); + const scores = timeline.flatMap(point => point.score === null || !finite(point.score) ? [] : [point.score]); + const qualities = timeline.map(point => point.quality).filter(finite); + const bpms = timeline.flatMap(point => point.bpm === null || !finite(point.bpm) ? [] : [point.bpm]); + const finalWindowStart = Math.max(0, input.actualDurationSeconds - 30); + const finalScores = timeline + .filter(point => point.timestampSeconds >= finalWindowStart) + .flatMap(point => point.score === null ? [] : [point.score]); + return { + schemaVersion: 1, + id: `coherence-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + algorithmVersion: COHERENCE_ALGORITHM_VERSION, + regulationSessionId: input.regulationSessionId ?? null, + beforeMeasurementId: input.beforeMeasurementId ?? null, + afterMeasurementId: input.afterMeasurementId ?? null, + cameraPosition: input.cameraPosition, + plannedDurationSeconds: input.plannedDurationSeconds, + actualDurationSeconds: Math.max(0, input.actualDurationSeconds), + startedAt: input.startedAt, + completedAt: input.completedAt, + completed: input.completed, + valid: scores.length >= 5, + meanScore: mean(scores), + finalScore: median(finalScores), + bestScore: scores.length === 0 ? null : Math.max(...scores), + validScoreSeconds: scores.length * 2, + meanQuality: mean(qualities) ?? 0, + initialBpm: bpms[0] ?? null, + finalBpm: bpms.at(-1) ?? null, + timeline, + }; +} + +let queue: Promise = Promise.resolve(); + +async function readAll(): Promise { + const stored = await readJSON(storageKeys.coherenceSessions, []); + if (!Array.isArray(stored)) return []; + return stored.filter((value): value is StoredCoherenceSession => { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return candidate.schemaVersion === 1 && + candidate.algorithmVersion === COHERENCE_ALGORITHM_VERSION && + typeof candidate.id === 'string' && Array.isArray(candidate.timeline); + }); +} + +export const coherenceSessionRepository = { + async getAll(): Promise { + await queue; + return readAll(); + }, + + async save(input: SaveCoherenceSessionInput): Promise { + const record = createRecord(input); + const operation = queue.then(async () => { + const current = await readAll(); + await writeJSON(storageKeys.coherenceSessions, [record, ...current]); + return record; + }); + queue = operation.then(() => undefined, () => undefined); + return operation; + }, + + async attachAfterMeasurement(sessionId: string, measurementId: string): Promise { + const operation = queue.then(async () => { + const current = await readAll(); + await writeJSON(storageKeys.coherenceSessions, current.map(session => + session.id === sessionId ? {...session, afterMeasurementId: measurementId} : session, + )); + }); + queue = operation.then(() => undefined, () => undefined); + return operation; + }, + + async attachRegulationSession(sessionId: string, regulationSessionId: string): Promise { + const operation = queue.then(async () => { + const current = await readAll(); + await writeJSON(storageKeys.coherenceSessions, current.map(session => + session.id === sessionId ? {...session, regulationSessionId} : session, + )); + }); + queue = operation.then(() => undefined, () => undefined); + return operation; + }, + + async deleteRelatedToMeasurement(measurementId: string): Promise { + const current = await this.getAll(); + await writeJSON(storageKeys.coherenceSessions, current.filter(session => + session.beforeMeasurementId !== measurementId && session.afterMeasurementId !== measurementId, + )); + }, + + async clearAll(): Promise { + await writeJSON(storageKeys.coherenceSessions, []); + }, +}; + +export const __coherenceRepositoryTesting = {createRecord, compactTimeline}; diff --git a/src/features/coherence/coherenceTypes.ts b/src/features/coherence/coherenceTypes.ts new file mode 100644 index 0000000..6941afe --- /dev/null +++ b/src/features/coherence/coherenceTypes.ts @@ -0,0 +1,54 @@ +export const COHERENCE_ALGORITHM_VERSION = 'HAPTIKA_COHERENCE_V1.0.0'; + +export type CoherenceSessionDurationSeconds = 180 | 300; +export type CoherenceScoreStatus = + | 'calibrating' + | 'poorSignal' + | 'scored'; + +export interface CoherenceBeat { + timestampSeconds: number; + intervalMs: number; + confidence: number; +} + +export interface CoherenceScoreComponents { + spectral: number; + phase: number; + regularity: number; +} + +export interface CoherenceScoreSnapshot { + timestampSeconds: number; + score: number | null; + quality: number; + bpm: number | null; + status: CoherenceScoreStatus; + components: CoherenceScoreComponents | null; + validIntervalCount: number; + coverage: number; +} + +export interface StoredCoherenceSession { + schemaVersion: 1; + id: string; + algorithmVersion: typeof COHERENCE_ALGORITHM_VERSION; + regulationSessionId: string | null; + beforeMeasurementId: string | null; + afterMeasurementId: string | null; + cameraPosition: 'front' | 'back'; + plannedDurationSeconds: CoherenceSessionDurationSeconds; + actualDurationSeconds: number; + startedAt: string; + completedAt: string; + completed: boolean; + valid: boolean; + meanScore: number | null; + finalScore: number | null; + bestScore: number | null; + validScoreSeconds: number; + meanQuality: number; + initialBpm: number | null; + finalBpm: number | null; + timeline: readonly CoherenceScoreSnapshot[]; +} diff --git a/src/features/onboarding/OnboardingScreen.tsx b/src/features/onboarding/OnboardingScreen.tsx new file mode 100644 index 0000000..509d0ba --- /dev/null +++ b/src/features/onboarding/OnboardingScreen.tsx @@ -0,0 +1,1352 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + ActivityIndicator, + Animated, + Image, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + useWindowDimensions, + View, +} from 'react-native'; +import { + Alarm, + ArrowRight, + BatteryMedium, + BatteryWarning, + CheckCircle, + DeviceMobile, + EnvelopeSimple, + EyeSlash, + FirstAidKit, + Heart, + Leaf, + Lightning, + LockSimple, + Moon, + PersonSimpleRun, + Sparkle, + Watch, + Waves, +} from 'phosphor-react-native'; + +import { colors, radii } from '../../theme'; +import { + defaultOnboardingAnswers, + onboardingRepository, + type OnboardingAnswers, + type OnboardingExperience, + type OnboardingGender, + type OnboardingLevel, +} from './onboardingRepository'; + +const silhouettes = require('./assets/haptika-silhouettes.png'); + +interface OnboardingScreenProps { + initialAnswers?: OnboardingAnswers; + onRequestHealthKit: () => Promise; + onComplete: (answers: OnboardingAnswers) => void; +} + +type IconComponent = React.ComponentType<{ + color?: string; + size?: number; + weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; +}>; + +const currentYear = new Date().getFullYear(); +const birthYears = Array.from( + { length: 100 }, + (_, index) => currentYear - 12 - index, +); + +const muted = '#BEC8CF'; +const bright = '#79D3FF'; +const canvas = '#030D1B'; +const panel = '#0C1C2D'; +const panelBorder = '#1A3A55'; +const dimPanel = '#101F30'; +const coral = '#FF6770'; +const teal = '#32DDCF'; + +function Progress({ + step, + complete = false, +}: { + step: number; + complete?: boolean; +}) { + return ( + + + {Array.from({ length: 7 }, (_, index) => ( + + ))} + + + {step + 1} / 7{complete ? ' COMPLÉTÉ' : ''} + + + ); +} + +function PrimaryButton({ + label = 'Continuer', + onPress, + busy = false, + glow = false, +}: { + label?: string; + onPress: () => void; + busy?: boolean; + glow?: boolean; +}) { + return ( + [ + styles.primaryButton, + glow && styles.primaryButtonGlow, + pressed && styles.pressed, + ]} + > + {busy ? ( + + ) : ( + + + {label} + + {label === 'Continuer' ? ( + + ) : null} + + )} + + ); +} + +function PageTitle({ children }: { children: React.ReactNode }) { + return {children}; +} + +function ModeCard({ + title, + icon: Icon, + tone, + tags, +}: { + title: string; + icon: IconComponent; + tone: string; + tags: string[]; +}) { + return ( + + + + + + {title} + + {tags.map(tag => ( + + {tag} + + ))} + + + + ); +} + +function ChoiceTile({ + label, + icon: Icon, + tone, + selected, + onPress, +}: { + label: string; + icon: IconComponent; + tone: string; + selected: boolean; + onPress: () => void; +}) { + return ( + [ + styles.choiceTile, + selected && styles.choiceTileSelected, + pressed && styles.pressed, + ]} + > + + + + {label} + + ); +} + +function AssessmentRow({ + title, + titleIcon: TitleIcon, + titleColor, + value, + onChange, + options, +}: { + title: string; + titleIcon: IconComponent; + titleColor: string; + value: OnboardingLevel; + onChange: (value: OnboardingLevel) => void; + options: Array<{ + value: OnboardingLevel; + label: string; + icon: IconComponent; + tone: string; + }>; +}) { + return ( + + + + {title} + + + {options.map(option => ( + onChange(option.value)} + /> + ))} + + + ); +} + +function PrivacyInfo({ + icon: Icon, + title, + description, +}: { + icon: IconComponent; + title: string; + description: string; +}) { + return ( + + + + {title} + {description} + + + ); +} + +function FeatureLine({ children }: { children: string }) { + return ( + + + {children} + + ); +} + +function ExperienceCard({ + kind, + selected, + onPress, +}: { + kind: OnboardingExperience; + selected: boolean; + onPress: () => void; +}) { + const watch = kind === 'watch'; + return ( + [ + styles.experienceCard, + watch && styles.experienceCardWatch, + selected && styles.experienceCardSelected, + pressed && styles.pressed, + ]} + > + {watch ? ( + + RECOMMANDÉ + + ) : null} + + + {watch ? ( + + ) : ( + + )} + + {watch ? 'iPhone + Apple Watch' : 'iPhone seul'} + + + + {selected ? : null} + + + + {watch + ? 'L’expérience complète pour un suivi biométrique enrichi et des retours haptiques en temps réel.' + : 'L’expérience de base pour des mesures ponctuelles de votre système nerveux.'} + + + {watch ? ( + <> + Suivi biométrique continu + Alertes de dérégulation du SNA + Guidage haptique des exercices + Mesures VRC passives précises + + ) : ( + <> + Mesure via la caméra (VRC) + Exercices de respiration basiques + Historique journalier + + )} + + + ); +} + +export function OnboardingScreen({ + initialAnswers = defaultOnboardingAnswers, + onRequestHealthKit, + onComplete, +}: OnboardingScreenProps): React.JSX.Element { + const { height } = useWindowDimensions(); + const scrollRef = useRef(null); + const advanceLockRef = useRef(false); + const fade = useRef(new Animated.Value(1)).current; + const [step, setStep] = useState(0); + const [answers, setAnswers] = useState(initialAnswers); + const [requestingHealth, setRequestingHealth] = useState(false); + const [yearPickerOpen, setYearPickerOpen] = useState(false); + + useEffect(() => { + scrollRef.current?.scrollTo({ y: 0, animated: false }); + fade.setValue(0); + Animated.timing(fade, { + toValue: 1, + duration: 260, + useNativeDriver: true, + }).start(); + }, [fade, step]); + + const updateAnswers = (patch: Partial) => { + setAnswers(current => ({ ...current, ...patch })); + }; + + const advance = async (): Promise => { + if (advanceLockRef.current) return; + advanceLockRef.current = true; + await onboardingRepository.saveDraft(answers).catch(() => undefined); + setStep(current => Math.min(current + 1, 6)); + setTimeout(() => { + advanceLockRef.current = false; + }, 450); + }; + + const goNext = (): void => { + advance().catch(() => undefined); + }; + + const requestHealth = async (): Promise => { + setRequestingHealth(true); + let enabled = false; + try { + enabled = await onRequestHealthKit(); + } finally { + const next = { ...answers, healthKitRequested: enabled }; + setAnswers(next); + await onboardingRepository.saveDraft(next).catch(() => undefined); + setRequestingHealth(false); + setStep(4); + } + }; + + const requestHealthAction = (): void => { + requestHealth().catch(() => undefined); + }; + + const finish = async (): Promise => { + await onboardingRepository.complete(answers); + onComplete(answers); + }; + + const finishAction = (): void => { + finish().catch(() => undefined); + }; + + const page = (() => { + switch (step) { + case 0: + return ( + + HAPTIKA + + + + Prenez en main votre système nerveux autonome et explorez de + nouvelles façons d’agir sur votre état de stress. + + + + + + ); + case 1: + return ( + + + COMPRENDRE LE SNA + Le système nerveux{`\n`}autonome + + Il régule en permanence votre respiration, votre rythme + cardiaque, votre sommeil et votre niveau de vigilance. + + + + + + + + {[ + 'Fonctionne en arrière-plan sans effort conscient.', + 'L’équilibre entre les deux modes est crucial.', + 'Le stress chronique bloque le mode récupération.', + 'La respiration influence directement ce système.', + ].map(point => ( + + + + + {point} + + ))} + + + + + + + ); + case 2: + return ( + + + Évaluez votre état actuel + + Ces données permettent d’ajuster vos protocoles de régulation + pour la session du jour. + + + + updateAnswers({ stress })} + options={[ + { value: 'low', label: 'FAIBLE', icon: Leaf, tone: teal }, + { + value: 'medium', + label: 'MODÉRÉ', + icon: Waves, + tone: '#48C9FF', + }, + { + value: 'high', + label: 'ÉLEVÉ', + icon: Lightning, + tone: '#FFAAA3', + }, + ]} + /> + updateAnswers({ sleep })} + options={[ + { + value: 'low', + label: 'SATISFAISANT', + icon: Sparkle, + tone: teal, + }, + { + value: 'medium', + label: 'MOYEN', + icon: Moon, + tone: '#48C9FF', + }, + { + value: 'high', + label: 'DIFFICULTÉS', + icon: Alarm, + tone: '#FFAAA3', + }, + ]} + /> + updateAnswers({ energy })} + options={[ + { + value: 'low', + label: 'EN FORME', + icon: PersonSimpleRun, + tone: teal, + }, + { + value: 'medium', + label: 'FATIGUÉ', + icon: BatteryMedium, + tone: '#48C9FF', + }, + { + value: 'high', + label: 'ÉPUISÉ', + icon: BatteryWarning, + tone: '#FFAAA3', + }, + ]} + /> + + + + + + + ); + case 3: + return ( + + + + + + Autoriser l’accès à Apple{`\n`}Santé + + + + + + Haptika utilise vos données physiologiques pour analyser votre + équilibre nerveux et personnaliser vos recommandations. + + + + + Fréquence cardiaque (VFC) + Qualité du sommeil + Activité physique + + + + + Données chiffrées de bout en bout.{`\n`}Jamais partagées. + + + + + + + Plus tard + + + + + ); + case 4: + return ( + + + Personnalisons votre{`\n`}expérience + + Ces informations permettent d’adapter certains repères + physiologiques et votre représentation dans Haptika. + + + + + + PRÉNOM + updateAnswers({ firstName })} + style={styles.textInput} + autoCapitalize="words" + returnKeyType="done" + /> + + + ANNÉE DE NAISSANCE + setYearPickerOpen(true)} + style={styles.yearButton} + > + + {answers.birthYear ?? 'Sélectionnez une année'} + + + + + + GENRE + + {(['female', 'male'] as OnboardingGender[]).map( + (gender, index) => ( + + {index === 1 ? ( + + ) : null} + updateAnswers({ gender })} + style={[ + styles.genderOption, + answers.gender === gender && + styles.genderOptionSelected, + ]} + > + + {gender === 'female' ? 'Femme' : 'Homme'} + + + + ), + )} + + + + + + + + + + + + ); + case 5: + return ( + + + Choisissez votre{`\n`}expérience Haptika + + Apple Watch enrichit la mesure et permet un suivi plus continu + de votre état. + + + + updateAnswers({ experience: 'iphone' })} + /> + updateAnswers({ experience: 'watch' })} + /> + + + + + + + ); + default: + return ( + + + + HAPTIKA + + + Bienvenue dans Haptika + + Votre profil est maintenant configuré.{`\n`}Découvrons ensemble + votre état actuel{`\n`}et les outils les plus adaptés à votre + {`\n`}situation. + + + + + + + + ); + } + })(); + + return ( + + + + {page} + + + setYearPickerOpen(false)} + > + setYearPickerOpen(false)} + > + undefined}> + Année de naissance + + {birthYears.map(year => ( + { + updateAnswers({ birthYear: year }); + setYearPickerOpen(false); + }} + style={[ + styles.yearRow, + answers.birthYear === year && styles.yearRowSelected, + ]} + > + + {year} + + + ))} + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: canvas }, + scrollContent: { flexGrow: 1, backgroundColor: canvas }, + animatedPage: { flex: 1 }, + standardPage: { + width: '100%', + maxWidth: 440, + minHeight: 874, + alignSelf: 'center', + paddingHorizontal: 20, + paddingTop: 82, + paddingBottom: 36, + }, + welcomePage: { + width: '100%', + maxWidth: 440, + minHeight: 874, + alignSelf: 'center', + paddingHorizontal: 20, + paddingTop: 103, + paddingBottom: 36, + }, + logo: { + fontSize: 28, + lineHeight: 34, + fontWeight: '300', + letterSpacing: 5.2, + color: '#DCE3F3', + textAlign: 'center', + }, + silhouettes: { width: 337, height: 369, alignSelf: 'center', marginTop: 20 }, + welcomeBottom: { marginTop: 21 }, + tagline: { + fontSize: 15, + lineHeight: 22, + color: muted, + textAlign: 'center', + paddingHorizontal: 20, + marginBottom: 24, + }, + primaryButton: { + height: 56, + width: '100%', + borderRadius: 14, + backgroundColor: dimPanel, + borderWidth: 1, + borderColor: panelBorder, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonGlow: { + shadowColor: bright, + shadowOpacity: 0.34, + shadowRadius: 20, + shadowOffset: { width: 0, height: 0 }, + }, + primaryButtonContent: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + primaryButtonText: { + fontSize: 13, + lineHeight: 22, + color: bright, + fontWeight: '400', + }, + startButtonText: { fontSize: 17, fontWeight: '600' }, + pressed: { opacity: 0.72, transform: [{ scale: 0.99 }] }, + progressBlock: { alignItems: 'center', marginTop: 16 }, + progressDots: { + height: 8, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + }, + progressDot: { + height: 8, + width: 8, + borderRadius: 99, + backgroundColor: '#59656E', + opacity: 0.76, + }, + progressDotActive: { width: 18, backgroundColor: '#4AB8E8', opacity: 1 }, + progressText: { + marginTop: 16, + fontSize: 10, + lineHeight: 16, + color: '#7F8E9C', + textAlign: 'center', + }, + progressTextComplete: { fontWeight: '700', letterSpacing: 1, color: muted }, + pageTitle: { + fontSize: 28, + lineHeight: 34, + fontWeight: '300', + color: '#DCE3F3', + textAlign: 'center', + }, + subtitle: { fontSize: 15, lineHeight: 20, color: muted, textAlign: 'center' }, + eyebrow: { + fontSize: 10, + lineHeight: 14, + fontWeight: '700', + letterSpacing: 1.5, + color: '#8695A3', + textAlign: 'center', + marginBottom: 18, + }, + educationHeader: { height: 178 }, + modeCards: { marginTop: 32, gap: 16 }, + modeCard: { + height: 86, + borderRadius: 14, + backgroundColor: panel, + borderWidth: 1, + borderColor: panelBorder, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + }, + modeIcon: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginRight: 16, + }, + modeBody: { flex: 1 }, + modeTitle: { + fontSize: 17, + lineHeight: 22, + fontWeight: '600', + marginBottom: 8, + }, + tagRow: { flexDirection: 'row', gap: 8 }, + tag: { + borderRadius: 12, + borderWidth: 1, + borderColor: panelBorder, + backgroundColor: '#0A1A2A', + paddingVertical: 4, + paddingHorizontal: 9, + }, + tagText: { fontSize: 10, lineHeight: 12, color: muted }, + educationalPoints: { + height: 134, + marginTop: 32, + borderRadius: 14, + backgroundColor: panel, + borderWidth: 1, + borderColor: panelBorder, + padding: 16, + justifyContent: 'space-between', + }, + educationalLine: { flexDirection: 'row', alignItems: 'center' }, + educationalBullet: { + width: 12, + height: 12, + borderRadius: 6, + borderWidth: 1, + borderColor: '#8EA6B8', + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + educationalBulletCenter: { + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: '#8EA6B8', + }, + educationalText: { fontSize: 12, lineHeight: 16, color: muted }, + actionArea: { marginTop: 32 }, + assessmentHeader: { height: 82 }, + assessmentForm: { marginTop: 32, gap: 22 }, + assessmentSection: { height: 136 }, + assessmentHeading: { + height: 22, + flexDirection: 'row', + gap: 8, + alignItems: 'center', + marginBottom: 12, + }, + assessmentTitle: { + fontSize: 17, + lineHeight: 22, + color: '#DCE3F3', + fontWeight: '600', + }, + choiceRow: { flex: 1, flexDirection: 'row', gap: 4 }, + choiceTile: { + flex: 1, + height: 102, + borderRadius: 7, + borderWidth: 1, + borderColor: panelBorder, + backgroundColor: panel, + alignItems: 'center', + paddingTop: 16, + }, + choiceTileSelected: { + borderColor: '#4FC8FB', + borderWidth: 1.5, + shadowColor: '#4FC8FB', + shadowOpacity: 0.24, + shadowRadius: 6, + }, + choiceIcon: { + width: 40, + height: 40, + borderRadius: 20, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center', + }, + choiceLabel: { + fontSize: 11, + lineHeight: 16, + color: '#D0D8E2', + marginTop: 12, + textAlign: 'center', + }, + healthHeader: { height: 160, alignItems: 'center' }, + healthHeart: { + width: 68, + height: 68, + borderRadius: 34, + backgroundColor: panel, + borderWidth: 1, + borderColor: panelBorder, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 24, + }, + healthCard: { + height: 318, + marginTop: 32, + marginHorizontal: 6, + borderRadius: 14, + borderWidth: 1, + borderColor: panelBorder, + backgroundColor: panel, + padding: 16, + }, + healthIntro: { flexDirection: 'row', alignItems: 'flex-start', gap: 16 }, + healthIntroText: { flex: 1, fontSize: 15, lineHeight: 24, color: muted }, + divider: { height: 1, backgroundColor: panelBorder, marginVertical: 14 }, + healthFeatures: { gap: 12 }, + featureLine: { + flexDirection: 'row', + alignItems: 'center', + gap: 9, + minHeight: 16, + }, + featureText: { fontSize: 12, lineHeight: 16, color: muted }, + encryptionBox: { + height: 58, + marginTop: 14, + borderRadius: 8, + backgroundColor: '#151F2C', + borderWidth: 1, + borderColor: '#202D3B', + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 13, + }, + encryptionText: { fontSize: 12, lineHeight: 16, color: muted }, + healthActions: { marginTop: 32 }, + laterButton: { height: 44, alignItems: 'center', justifyContent: 'center' }, + laterText: { fontSize: 12, lineHeight: 20, color: muted }, + profileHeader: { height: 158, paddingHorizontal: 4 }, + profileForm: { height: 144, marginTop: 32 }, + profileFieldsRow: { + height: 57, + flexDirection: 'row', + gap: 11, + marginBottom: 20, + }, + profileField: { flex: 1 }, + fieldLabel: { + fontSize: 10, + lineHeight: 14, + letterSpacing: 1.2, + fontWeight: '700', + color: '#8794A5', + marginBottom: 12, + }, + textInput: { + height: 32, + backgroundColor: '#F7F7F9', + borderWidth: 1, + borderColor: '#F7F7F9', + color: '#1E2934', + fontSize: 12, + paddingHorizontal: 11, + paddingVertical: 0, + }, + yearButton: { + height: 32, + borderWidth: 1, + borderColor: '#485563', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 11, + }, + yearText: { flex: 1, fontSize: 11, color: '#DCE3F3' }, + yearTextSelected: { color: '#F4FAFF' }, + chevronDown: { transform: [{ rotate: '90deg' }] }, + genderControl: { + height: 42, + borderRadius: 8, + backgroundColor: '#171F2B', + borderWidth: 1, + borderColor: '#25303D', + flexDirection: 'row', + overflow: 'hidden', + }, + genderOption: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + genderOptionSelected: { backgroundColor: '#1D2B3C' }, + genderDivider: { + width: 1, + height: 25, + backgroundColor: '#8A96A2', + alignSelf: 'center', + }, + genderText: { + fontSize: 17, + lineHeight: 22, + color: '#B8C0CB', + fontWeight: '600', + }, + genderTextSelected: { color: '#E6ECF3' }, + privacyStack: { marginTop: 32, gap: 12 }, + privacyInfo: { + height: 92, + borderRadius: 14, + borderWidth: 1, + borderColor: panelBorder, + backgroundColor: panel, + flexDirection: 'row', + padding: 16, + gap: 12, + }, + privacyInfoText: { flex: 1 }, + privacyInfoTitle: { + fontSize: 17, + lineHeight: 22, + color: '#DCE3F3', + fontWeight: '600', + }, + privacyInfoDescription: { + fontSize: 12, + lineHeight: 16, + color: muted, + marginTop: 4, + }, + experienceIntro: { height: 120, paddingHorizontal: 18 }, + experienceOptions: { marginTop: 32, gap: 16 }, + experienceCard: { + height: 180, + width: 348, + borderRadius: 14, + backgroundColor: panel, + borderWidth: 1, + borderColor: panelBorder, + padding: 16, + alignSelf: 'flex-start', + }, + experienceCardWatch: { height: 209 }, + experienceCardSelected: { borderColor: '#51C9FA', borderWidth: 1.5 }, + experienceHeader: { + height: 28, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + experienceHeading: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + experienceTitle: { + fontSize: 17, + lineHeight: 22, + color: '#DCE3F3', + fontWeight: '600', + }, + radio: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 2, + borderColor: '#4B5B67', + alignItems: 'center', + justifyContent: 'center', + }, + radioSelected: { backgroundColor: '#4AB8E8', borderColor: '#4AB8E8' }, + radioCenter: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: canvas, + }, + experienceDescription: { + fontSize: 12, + lineHeight: 16, + color: muted, + marginTop: 12, + }, + featureList: { marginTop: 12, gap: 8 }, + recommendedBadge: { + position: 'absolute', + top: -12, + alignSelf: 'center', + zIndex: 2, + height: 22, + borderRadius: 12, + backgroundColor: '#57C7EC', + paddingHorizontal: 13, + alignItems: 'center', + justifyContent: 'center', + }, + recommendedText: { + fontSize: 10, + lineHeight: 12, + color: '#0B3A59', + fontWeight: '700', + }, + finalPage: { justifyContent: 'center', paddingTop: 0 }, + finalContent: { width: '100%', alignItems: 'center' }, + finalLogoBlock: { alignItems: 'center' }, + finalLogo: { + fontSize: 28, + lineHeight: 34, + fontWeight: '300', + letterSpacing: 4.4, + color: '#DCE3F3', + }, + logoDivider: { + width: 48, + height: 1, + backgroundColor: bright, + opacity: 0.5, + marginTop: 12, + }, + finalTitle: { + fontSize: 28, + lineHeight: 34, + fontWeight: '300', + color: bright, + textAlign: 'center', + marginTop: 32, + }, + finalDescription: { + fontSize: 15, + lineHeight: 24, + color: muted, + textAlign: 'center', + marginTop: 11, + }, + finalActions: { width: '100%', marginTop: 64 }, + modalBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.72)', + alignItems: 'center', + justifyContent: 'center', + padding: 24, + }, + yearModal: { + width: '100%', + maxWidth: 340, + height: 470, + borderRadius: radii.md, + borderWidth: 1, + borderColor: panelBorder, + backgroundColor: panel, + padding: 16, + }, + yearModalTitle: { + fontSize: 20, + lineHeight: 26, + color: colors.text, + fontWeight: '600', + marginBottom: 12, + textAlign: 'center', + }, + yearList: { flex: 1 }, + yearRow: { + height: 48, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: panelBorder, + alignItems: 'center', + justifyContent: 'center', + }, + yearRowSelected: { backgroundColor: colors.accentSoft, borderRadius: 8 }, + yearRowText: { fontSize: 17, color: muted }, + yearRowTextSelected: { color: bright, fontWeight: '700' }, +}); diff --git a/src/features/onboarding/assets/haptika-silhouettes.png b/src/features/onboarding/assets/haptika-silhouettes.png new file mode 100644 index 0000000..ffdab93 Binary files /dev/null and b/src/features/onboarding/assets/haptika-silhouettes.png differ diff --git a/src/features/onboarding/onboardingRepository.test.ts b/src/features/onboarding/onboardingRepository.test.ts new file mode 100644 index 0000000..436d04d --- /dev/null +++ b/src/features/onboarding/onboardingRepository.test.ts @@ -0,0 +1,44 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { + defaultOnboardingAnswers, + onboardingRepository, +} from './onboardingRepository'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +const storage = AsyncStorage as jest.Mocked; + +describe('onboardingRepository', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns no state on a fresh install', async () => { + storage.getItem.mockResolvedValue(null); + await expect(onboardingRepository.get()).resolves.toBeNull(); + }); + + it('persists the completed profile', async () => { + storage.setItem.mockResolvedValue(); + await onboardingRepository.complete({ + ...defaultOnboardingAnswers, + firstName: 'Sacha', + birthYear: 1998, + }); + const [, serialized] = storage.setItem.mock.calls[0] ?? []; + const value = JSON.parse(serialized ?? '{}'); + expect(value).toMatchObject({ + schemaVersion: 1, + completed: true, + firstName: 'Sacha', + birthYear: 1998, + }); + expect(value.completedAt).toEqual(expect.any(String)); + }); +}); diff --git a/src/features/onboarding/onboardingRepository.ts b/src/features/onboarding/onboardingRepository.ts new file mode 100644 index 0000000..8e9f4cb --- /dev/null +++ b/src/features/onboarding/onboardingRepository.ts @@ -0,0 +1,84 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const STORAGE_KEY = '@haptika/onboarding/v1'; + +export type OnboardingLevel = 'low' | 'medium' | 'high'; +export type OnboardingGender = 'female' | 'male' | null; +export type OnboardingExperience = 'iphone' | 'watch'; + +export interface OnboardingAnswers { + stress: OnboardingLevel; + sleep: OnboardingLevel; + energy: OnboardingLevel; + firstName: string; + birthYear: number | null; + gender: OnboardingGender; + experience: OnboardingExperience; + healthKitRequested: boolean; +} + +export interface OnboardingState extends OnboardingAnswers { + schemaVersion: 1; + completed: boolean; + completedAt: string | null; +} + +export const defaultOnboardingAnswers: OnboardingAnswers = { + stress: 'low', + sleep: 'medium', + energy: 'high', + firstName: '', + birthYear: null, + gender: null, + experience: 'watch', + healthKitRequested: false, +}; + +function parseState(raw: string | null): OnboardingState | null { + if (raw === null) return null; + try { + const value = JSON.parse(raw) as Partial; + if (value.schemaVersion !== 1) return null; + return { + ...defaultOnboardingAnswers, + ...value, + schemaVersion: 1, + completed: value.completed === true, + completedAt: + typeof value.completedAt === 'string' ? value.completedAt : null, + }; + } catch { + return null; + } +} + +async function persist( + answers: OnboardingAnswers, + completed: boolean, +): Promise { + const state: OnboardingState = { + ...answers, + schemaVersion: 1, + completed, + completedAt: completed ? new Date().toISOString() : null, + }; + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)); +} + +export const onboardingRepository = { + async get(): Promise { + return parseState(await AsyncStorage.getItem(STORAGE_KEY)); + }, + + async saveDraft(answers: OnboardingAnswers): Promise { + await persist(answers, false); + }, + + async complete(answers: OnboardingAnswers): Promise { + await persist(answers, true); + }, + + async reset(): Promise { + await AsyncStorage.removeItem(STORAGE_KEY); + }, +}; diff --git a/src/features/ppg/assets/finger-camera-placement.png b/src/features/ppg/assets/finger-camera-placement.png new file mode 100644 index 0000000..028c2b9 Binary files /dev/null and b/src/features/ppg/assets/finger-camera-placement.png differ diff --git a/src/features/ppg/assets/hrv-silhouette.png b/src/features/ppg/assets/hrv-silhouette.png new file mode 100644 index 0000000..6072c84 Binary files /dev/null and b/src/features/ppg/assets/hrv-silhouette.png differ diff --git a/src/features/ppg/camera/PPGCameraView.tsx b/src/features/ppg/camera/PPGCameraView.tsx new file mode 100644 index 0000000..e358554 --- /dev/null +++ b/src/features/ppg/camera/PPGCameraView.tsx @@ -0,0 +1,414 @@ +import React, {useCallback, useEffect, useMemo, useRef} from 'react'; +import {StyleSheet, View} from 'react-native'; +import {scheduleOnRN} from 'react-native-worklets'; +import { + Camera, + type CameraDevice, + type CameraRef, + useCameraDevices, + useFrameOutput, +} from 'react-native-vision-camera'; +import {haptikaPPG, type PPGFrameUpdate} from 'react-native-haptika-ppg'; + +import type {PPGCameraError} from './cameraTypes'; + +interface PPGCameraViewProps { + isActive: boolean; + cameraPosition?: 'back' | 'front'; + torchEnabled: boolean; + lockOptics: boolean; + onCameraStarted: () => void; + onFrameUpdate: (update: PPGFrameUpdate) => void; + onInterrupted: (reason: string) => void; + onError: (error: PPGCameraError) => void; +} + +const PPG_TARGET_RESOLUTION = Object.freeze({width: 640, height: 480}); + +export function PPGCameraView({ + isActive, + cameraPosition = 'back', + torchEnabled, + lockOptics, + onCameraStarted, + onFrameUpdate, + onInterrupted, + onError, +}: PPGCameraViewProps): React.JSX.Element { + const cameraRef = useRef(null); + const controllerRef = useRef(undefined); + const hasReportedTerminalEventRef = useRef(false); + const torchConfiguredRef = useRef(false); + const opticsLockedRef = useRef(false); + const consecutiveFrameErrorsRef = useRef(0); + const hasReceivedUpdateRef = useRef(false); + const cameraDevices = useCameraDevices(); + const physicalBackDevices = cameraDevices.filter( + candidate => candidate.position === 'back' && !candidate.isVirtualDevice, + ); + const ultraWideDevice = physicalBackDevices.find( + candidate => candidate.type === 'ultra-wide-angle' && candidate.hasTorch, + ); + const virtualUltraWideDevice = cameraDevices.find( + candidate => + candidate.position === 'back' && + candidate.isVirtualDevice && + candidate.hasTorch && + candidate.physicalDevices.some( + physicalDevice => physicalDevice.type === 'ultra-wide-angle', + ) && + candidate.minZoom <= 0.5 && + candidate.maxZoom >= 0.5, + ); + // Contact PPG apps commonly use the 0.5× module because its wider field of + // view makes it easier to cover the lens and the flash with one fingertip. + // Prefer that physical module when it exposes a torch, then fall back to the + // 1× wide camera. Never use the virtual triple-camera device: it can switch + // lenses during a measurement. + const frontDevice = cameraDevices.find( + candidate => + candidate.position === 'front' && + !candidate.isVirtualDevice && + candidate.type === 'wide-angle', + ) ?? cameraDevices.find(candidate => candidate.position === 'front'); + const device: CameraDevice | undefined = + cameraPosition === 'front' + ? frontDevice + : ultraWideDevice ?? + virtualUltraWideDevice ?? + physicalBackDevices.find(candidate => candidate.type === 'wide-angle'); + const initialZoom = + device?.isVirtualDevice === true + ? Math.min(Math.max(0.5, device.minZoom), device.maxZoom) + : undefined; + + const forceTorchOff = useCallback(async (): Promise => { + try { + const controller = cameraRef.current?.controller ?? controllerRef.current; + await controller?.setTorchMode('off'); + } catch { + // Best effort: the session may already be torn down. + } + }, []); + + const reportCameraError = useCallback( + (error: PPGCameraError): void => { + if (hasReportedTerminalEventRef.current) { + return; + } + hasReportedTerminalEventRef.current = true; + forceTorchOff(); + onError(error); + }, + [forceTorchOff, onError], + ); + + const reportInterruption = useCallback( + (reason: string): void => { + if (hasReportedTerminalEventRef.current) { + return; + } + hasReportedTerminalEventRef.current = true; + forceTorchOff(); + onInterrupted(reason); + }, + [forceTorchOff, onInterrupted], + ); + + const onFrameProcessorError = useCallback( + (message: string): void => { + consecutiveFrameErrorsRef.current += 1; + // A camera can deliver a malformed frame while changing exposure. Skip + // isolated failures; only a sustained broken pipeline ends the session. + if (consecutiveFrameErrorsRef.current >= 10) { + reportCameraError({code: 'cameraRuntime', message}); + } + }, + [reportCameraError], + ); + + const reportFrameUpdate = useCallback( + (update: PPGFrameUpdate): void => { + consecutiveFrameErrorsRef.current = 0; + hasReceivedUpdateRef.current = true; + onFrameUpdate(update); + }, + [onFrameUpdate], + ); + + const frameOutput = useFrameOutput({ + pixelFormat: 'yuv', + targetResolution: PPG_TARGET_RESOLUTION, + dropFramesWhileBusy: true, + enablePhysicalBufferRotation: false, + // VisionCamera logs every intentionally dropped busy frame when this + // callback is omitted. Gaps are already measured by the native quality + // evaluator, so keep the expected backpressure out of React Native LogBox. + onFrameDropped() {}, + onFrame(frame) { + 'worklet'; + try { + const update = haptikaPPG.processFrame(frame); + if (update !== undefined) { + scheduleOnRN(reportFrameUpdate, update); + } + } catch (error) { + const message = + error instanceof Error + ? error.message + : 'Le traitement natif d’une image caméra a échoué.'; + scheduleOnRN(onFrameProcessorError, message); + } finally { + frame.dispose(); + } + }, + }); + + const outputs = useMemo(() => [frameOutput], [frameOutput]); + const constraints = useMemo(() => [{fps: 30 as const}], []); + + useEffect(() => { + opticsLockedRef.current = false; + consecutiveFrameErrorsRef.current = 0; + hasReceivedUpdateRef.current = false; + }, [device?.id, isActive]); + + useEffect(() => { + if (!isActive || device === undefined) return; + const watchdog = setTimeout(() => { + if (!hasReceivedUpdateRef.current) { + reportCameraError({ + code: 'cameraRuntime', + message: + 'La caméra a démarré mais aucune trame n’atteint le capteur PPG.', + }); + } + }, 6_000); + return () => clearTimeout(watchdog); + }, [device, isActive, reportCameraError]); + + const configureTorch = useCallback( + async (): Promise => { + try { + const controller = + cameraRef.current?.controller ?? controllerRef.current; + if (controller === undefined) { + return { + code: 'cameraRuntime', + message: 'Le contrôleur de la caméra est indisponible.', + }; + } + const {device: activeDevice} = controller; + if (!activeDevice.hasTorch) { + return { + code: 'torchUnavailable', + message: 'Le flash de cet iPhone est indisponible.', + }; + } + + if (activeDevice.supportsTorchStrength) { + // Full power clips the fingertip's red channel. Keep enough light for + // PPG while preserving the small color changes caused by each pulse. + const desiredStrength = Math.min( + Math.max(0.15, activeDevice.minTorchStrength), + activeDevice.maxTorchStrength, + ); + try { + if ( + controller.torchMode !== 'on' || + Math.abs(controller.torchStrength - desiredStrength) > 0.01 + ) { + await controller.enableTorchWithStrength(desiredStrength); + } + } catch { + // Some camera combinations advertise strength control but reject + // it at runtime. Fall back to the standard continuous torch. + await controller.setTorchMode('on'); + } + } else if (controller.torchMode !== 'on') { + await controller.setTorchMode('on'); + } + + if (!opticsLockedRef.current && activeDevice.supportsExposureBias) { + try { + const desiredBias = Math.min( + Math.max(-1, activeDevice.minExposureBias), + activeDevice.maxExposureBias, + ); + if (Math.abs(controller.exposureBias - desiredBias) > 0.05) { + await controller.setExposureBias(desiredBias); + } + } catch { + // Exposure bias is optional; do not turn a torch capability issue + // into a terminal camera error. + } + } + return null; + } catch (error) { + return { + code: 'cameraRuntime', + message: + error instanceof Error + ? error.message + : 'Impossible de configurer le flash.', + }; + } + }, + [], + ); + + useEffect(() => { + if (!lockOptics || opticsLockedRef.current || !isActive) { + return; + } + + let cancelled = false; + const timer = setTimeout(() => { + const controller = cameraRef.current?.controller ?? controllerRef.current; + if (cancelled || controller === undefined) { + return; + } + + // The finger is stable when this effect starts. Locking the automatic + // camera controls stops AE/AWB/AF corrections from looking like pulse or + // motion in the color time series. + (async (): Promise => { + try { + if (controller.device.supportsExposureLocking) { + await controller.lockCurrentExposure(); + } + } catch { + // Optional stabilization; automatic exposure remains usable. + } + try { + if (controller.device.supportsWhiteBalanceLocking) { + await controller.lockCurrentWhiteBalance(); + } + } catch { + // Optional stabilization; automatic white balance remains usable. + } + try { + if (controller.device.supportsFocusLocking) { + await controller.lockCurrentFocus(); + } + } catch { + // Focus has little effect on spatially averaged fingertip color. + } + if (!cancelled) { + opticsLockedRef.current = true; + } + })(); + }, 1_000); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [isActive, lockOptics]); + + useEffect(() => { + if (device?.position === 'front') { + // The front-camera method is illuminated by the screen. It intentionally + // has no torch and must never fail the measurement for that reason. + torchConfiguredRef.current = false; + forceTorchOff(); + return; + } + if (!torchEnabled || !isActive) { + if (torchConfiguredRef.current) { + torchConfiguredRef.current = false; + forceTorchOff(); + } + return; + } + + if (torchConfiguredRef.current) { + return; + } + + torchConfiguredRef.current = true; + let cancelled = false; + const ensureTorchIsOn = (): void => { + configureTorch().then(error => { + if (cancelled) { + return; + } + if (error !== null) { + torchConfiguredRef.current = false; + reportCameraError(error); + } + }); + }; + ensureTorchIsOn(); + const torchWatchdog = setInterval(ensureTorchIsOn, 750); + return () => { + clearInterval(torchWatchdog); + cancelled = true; + }; + }, [ + configureTorch, + forceTorchOff, + isActive, + device?.position, + reportCameraError, + torchEnabled, + ]); + + useEffect(() => { + return () => { + torchConfiguredRef.current = false; + forceTorchOff(); + }; + }, [forceTorchOff]); + + useEffect(() => { + if (device !== undefined || !isActive) { + return; + } + const timer = setTimeout(() => { + reportCameraError({ + code: 'cameraUnavailable', + message: + cameraPosition === 'front' + ? 'La caméra avant est indisponible.' + : 'Aucune caméra arrière avec flash n’est disponible.', + }); + }, 2_000); + return () => clearTimeout(timer); + }, [cameraPosition, device, isActive, reportCameraError]); + + if (device === undefined) { + return ; + } + + return ( + initialZoom} + isActive={isActive} + outputs={outputs} + constraints={constraints} + onStarted={() => { + controllerRef.current = cameraRef.current?.controller; + onCameraStarted(); + }} + onInterruptionStarted={reason => reportInterruption(String(reason))} + onError={error => + reportCameraError({code: 'cameraRuntime', message: error.message}) + } + /> + ); +} + +const styles = StyleSheet.create({ + unavailable: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: '#08090B', + }, +}); diff --git a/src/features/ppg/camera/cameraTypes.ts b/src/features/ppg/camera/cameraTypes.ts new file mode 100644 index 0000000..4ddfc53 --- /dev/null +++ b/src/features/ppg/camera/cameraTypes.ts @@ -0,0 +1,12 @@ +export type { + FingerContactState, + NativePPGConfiguration, + NativePPGProcessingResult, + PPGFrameUpdate, + ThermalState, +} from 'react-native-haptika-ppg'; + +export interface PPGCameraError { + code: 'cameraUnavailable' | 'torchUnavailable' | 'cameraRuntime'; + message: string; +} diff --git a/src/features/ppg/camera/usePPGCamera.ts b/src/features/ppg/camera/usePPGCamera.ts new file mode 100644 index 0000000..800f640 --- /dev/null +++ b/src/features/ppg/camera/usePPGCamera.ts @@ -0,0 +1,23 @@ +import {useCallback} from 'react'; +import {useCameraPermission} from 'react-native-vision-camera'; + +export interface PPGCameraPermissionController { + hasCameraPermission: boolean; + requestCameraPermission: () => Promise; +} + +export function usePPGCamera(): PPGCameraPermissionController { + const {hasPermission, requestPermission} = useCameraPermission(); + + const requestCameraPermission = useCallback(async (): Promise => { + if (hasPermission) { + return true; + } + return requestPermission(); + }, [hasPermission, requestPermission]); + + return { + hasCameraPermission: hasPermission, + requestCameraPermission, + }; +} diff --git a/src/features/ppg/components/ConfidenceIndicator.tsx b/src/features/ppg/components/ConfidenceIndicator.tsx new file mode 100644 index 0000000..0258610 --- /dev/null +++ b/src/features/ppg/components/ConfidenceIndicator.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import {colors, radii, spacing} from '../../../theme'; + +interface ConfidenceIndicatorProps { + confidence: number; +} + +export function ConfidenceIndicator({ + confidence, +}: ConfidenceIndicatorProps): React.JSX.Element { + const percent = Math.round(Math.min(Math.max(confidence, 0), 1) * 100); + const label = percent >= 75 ? 'Élevée' : percent >= 50 ? 'Modérée' : 'Faible'; + return ( + + + Confiance + {label} + + + + + + {percent} % + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: spacing.md, + borderRadius: radii.md, + backgroundColor: colors.surfaceElevated, + }, + eyebrow: { + color: colors.muted, + fontSize: 11, + textTransform: 'uppercase', + letterSpacing: 0.8, + }, + label: { + color: colors.text, + fontWeight: '700', + marginTop: 2, + }, + right: { + flexDirection: 'row', + alignItems: 'center', + }, + track: { + width: 84, + height: 6, + borderRadius: radii.pill, + backgroundColor: colors.border, + overflow: 'hidden', + }, + fill: { + height: '100%', + backgroundColor: colors.blue, + }, + percent: { + color: colors.muted, + fontVariant: ['tabular-nums'], + width: 44, + textAlign: 'right', + }, +}); diff --git a/src/features/ppg/components/FingerContactIndicator.tsx b/src/features/ppg/components/FingerContactIndicator.tsx new file mode 100644 index 0000000..ff64f6b --- /dev/null +++ b/src/features/ppg/components/FingerContactIndicator.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import {colors, radii, spacing} from '../../../theme'; +import type {FingerContactState} from '../measurement/measurementTypes'; + +interface FingerContactIndicatorProps { + state: FingerContactState; + confidence: number; +} + +const labels: Record = { + absent: 'Doigt non détecté', + adjusting: 'Ajustez votre doigt', + valid: 'Contact stable', + tooMuchPressure: 'Relâchez légèrement la pression', + overexposed: 'Décalez légèrement votre doigt', + unstable: 'Restez immobile', +}; + +export function FingerContactIndicator({ + state, + confidence, +}: FingerContactIndicatorProps): React.JSX.Element { + const isValid = state === 'valid'; + return ( + + + + {labels[state]} + + Stabilité {Math.round(Math.min(Math.max(confidence, 0), 1) * 100)} % + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'stretch', + padding: spacing.md, + borderRadius: radii.md, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: 'rgba(20,22,27,0.92)', + }, + dot: { + width: 12, + height: 12, + borderRadius: 6, + marginRight: spacing.md, + }, + validDot: { + backgroundColor: colors.good, + }, + pendingDot: { + backgroundColor: colors.medium, + }, + copy: { + flex: 1, + }, + label: { + color: colors.text, + fontWeight: '700', + fontSize: 15, + }, + detail: { + color: colors.muted, + fontSize: 12, + marginTop: 3, + }, +}); diff --git a/src/features/ppg/components/HaptikaChrome.tsx b/src/features/ppg/components/HaptikaChrome.tsx new file mode 100644 index 0000000..0a7a39e --- /dev/null +++ b/src/features/ppg/components/HaptikaChrome.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; +import { + FirstAidKit, + GearSix, + List, + Toolbox, + TrendUp, + User, + Waveform, +} from 'phosphor-react-native'; + +import {colors} from '../../../theme'; + +type IconComponent = React.ComponentType<{ + color?: string; + size?: number; + weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; +}>; + +interface HaptikaHeaderProps { + onMenuPress?: () => void; + onSettingsPress?: () => void; +} + +export function HaptikaHeader({ + onMenuPress, + onSettingsPress, +}: HaptikaHeaderProps): React.JSX.Element { + const insets = useSafeAreaInsets(); + + return ( + + + + + HAPTIKA + + + + + ); +} + +interface BottomNavigationProps { + onHistory: () => void; + onSos: () => void; + onTools: () => void; +} + +const items: readonly { + label: string; + icon: IconComponent; + key: 'hrv' | 'sos' | 'tools' | 'progress' | 'profile'; +}[] = [ + {key: 'hrv', label: 'HRV', icon: Waveform}, + {key: 'sos', label: 'SOS', icon: FirstAidKit}, + {key: 'tools', label: 'Outils', icon: Toolbox}, + {key: 'progress', label: 'Évolution', icon: TrendUp}, + {key: 'profile', label: 'Profil', icon: User}, +]; + +export function BottomNavigation({ + onHistory, + onSos, + onTools, +}: BottomNavigationProps): React.JSX.Element { + const actionFor = (key: (typeof items)[number]['key']): (() => void) => { + if (key === 'progress') return onHistory; + if (key === 'sos') return onSos; + if (key === 'tools') return onTools; + return () => undefined; + }; + + return ( + + {items.map(item => { + const Icon = item.icon; + const selected = item.key === 'hrv'; + return ( + + + + {item.label} + + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + header: { + height: 64, + flexShrink: 0, + paddingHorizontal: 20, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(98,212,255,0.12)', + backgroundColor: '#071321', + }, + headerButton: { + width: 32, + height: 40, + alignItems: 'flex-start', + justifyContent: 'center', + }, + brand: { + color: '#DCE5F3', + fontSize: 27, + lineHeight: 34, + fontWeight: '300', + letterSpacing: 3.1, + }, + bottomNavigation: { + height: 80, + flexShrink: 0, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-around', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(168,201,232,0.16)', + backgroundColor: '#121C2A', + paddingHorizontal: 8, + }, + navItem: { + minWidth: 60, + height: 58, + alignItems: 'center', + justifyContent: 'center', + gap: 2, + }, + navLabel: { + color: '#B7C2D1', + fontSize: 12, + lineHeight: 16, + }, + navLabelSelected: { + color: colors.accent, + }, +}); diff --git a/src/features/ppg/components/MeasurementProgress.tsx b/src/features/ppg/components/MeasurementProgress.tsx new file mode 100644 index 0000000..e41270d --- /dev/null +++ b/src/features/ppg/components/MeasurementProgress.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import {colors, radii, spacing} from '../../../theme'; + +interface MeasurementProgressProps { + progress: number; + label: string; + secondsRemaining?: number; +} + +export function MeasurementProgress({ + progress, + label, + secondsRemaining, +}: MeasurementProgressProps): React.JSX.Element { + const normalized = Math.min(Math.max(progress, 0), 1); + return ( + + + {label} + {secondsRemaining !== undefined ? ( + {Math.max(0, Math.ceil(secondsRemaining))} s + ) : null} + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + width: '100%', + }, + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.sm, + }, + label: { + color: colors.text, + fontSize: 15, + fontWeight: '600', + }, + time: { + color: colors.muted, + fontVariant: ['tabular-nums'], + }, + track: { + height: 8, + borderRadius: radii.pill, + backgroundColor: colors.border, + overflow: 'hidden', + }, + fill: { + height: '100%', + borderRadius: radii.pill, + backgroundColor: colors.accent, + }, +}); diff --git a/src/features/ppg/components/MetricInsightCard.tsx b/src/features/ppg/components/MetricInsightCard.tsx new file mode 100644 index 0000000..7266f9f --- /dev/null +++ b/src/features/ppg/components/MetricInsightCard.tsx @@ -0,0 +1,110 @@ +import React, {useState} from 'react'; +import {Pressable, StyleSheet, Text, View} from 'react-native'; + +import {colors, spacing} from '../../../theme'; +import type {MetricInsight, MetricInsightTone} from '../insights/metricInsights'; +import {MetricTrendChart} from './MetricTrendChart'; + +const toneColor: Record = { + good: colors.good, + neutral: colors.accent, + attention: '#F5C66A', + unavailable: colors.muted, +}; + +export function MetricInsightCard({ + insight, + defaultExpanded = false, +}: { + insight: MetricInsight; + defaultExpanded?: boolean; +}): React.JSX.Element { + const [expanded, setExpanded] = useState(defaultExpanded); + const color = toneColor[insight.tone]; + const value = + insight.value === null ? '—' : insight.value.toFixed(insight.digits); + + return ( + + setExpanded(current => !current)} + style={({pressed}) => [styles.header, pressed && styles.pressed]}> + + + {value} + {insight.unit} + {insight.shortLabel} + + + + {insight.status} + + + + {expanded ? '−' : '+'} + + + + {expanded ? ( + + {insight.description} + + {insight.methodology} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + card: { + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + paddingVertical: spacing.md, + }, + header: { + minHeight: 52, + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: spacing.md, + }, + pressed: {opacity: 0.72}, + valueBlock: {flex: 1}, + valueRow: {flexDirection: 'row', alignItems: 'baseline', flexWrap: 'wrap'}, + value: { + color: colors.text, + fontSize: 30, + fontWeight: '900', + letterSpacing: -1, + fontVariant: ['tabular-nums'], + textDecorationLine: 'underline', + }, + unit: {color: colors.text, fontSize: 17, marginLeft: 4, fontWeight: '700'}, + label: {color: colors.text, fontSize: 19, marginLeft: spacing.md, fontWeight: '600'}, + statusRow: {flexDirection: 'row', alignItems: 'center', marginTop: 4}, + dot: {width: 7, height: 7, borderRadius: 4, marginRight: spacing.xs}, + status: {fontSize: 12, fontWeight: '700'}, + chevronCircle: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.border, + }, + chevron: {color: colors.muted, fontSize: 18, fontWeight: '500'}, + body: {paddingTop: spacing.sm}, + description: {color: colors.text, fontSize: 15, lineHeight: 22, marginBottom: spacing.sm}, + methodology: {color: colors.muted, fontSize: 10, lineHeight: 15, marginTop: spacing.sm}, +}); diff --git a/src/features/ppg/components/MetricTrendChart.tsx b/src/features/ppg/components/MetricTrendChart.tsx new file mode 100644 index 0000000..478efe8 --- /dev/null +++ b/src/features/ppg/components/MetricTrendChart.tsx @@ -0,0 +1,163 @@ +import React, {useMemo} from 'react'; +import {StyleSheet, Text, View} from 'react-native'; +import Svg, {Circle, Line, Path, Rect} from 'react-native-svg'; + +import {colors, spacing} from '../../../theme'; +import type {MetricTrendPoint} from '../insights/metricInsights'; + +interface MetricTrendChartProps { + points: readonly MetricTrendPoint[]; + color: string; + unit: string; + digits: number; + referenceRange: {minimum: number; maximum: number} | null; +} + +const WIDTH = 320; +const HEIGHT = 104; +const PAD_X = 8; +const PAD_Y = 13; + +const dateLabel = (value: string): string => + new Date(value).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + }); + +const createPath = (coordinates: readonly {x: number; y: number}[]): string => { + if (coordinates.length === 0) return ''; + if (coordinates.length === 1) { + const only = coordinates[0]!; + return `M ${only.x} ${only.y}`; + } + return coordinates.reduce((path, point, index) => { + if (index === 0) return `M ${point.x} ${point.y}`; + const previous = coordinates[index - 1]!; + const controlX = (previous.x + point.x) / 2; + return `${path} C ${controlX} ${previous.y}, ${controlX} ${point.y}, ${point.x} ${point.y}`; + }, ''); +}; + +export function MetricTrendChart({ + points, + color, + unit, + digits, + referenceRange, +}: MetricTrendChartProps): React.JSX.Element { + const model = useMemo(() => { + if (points.length === 0) return null; + const allValues = [ + ...points.map(point => point.value), + ...(referenceRange === null + ? [] + : [referenceRange.minimum, referenceRange.maximum]), + ]; + const rawMinimum = Math.min(...allValues); + const rawMaximum = Math.max(...allValues); + const padding = Math.max((rawMaximum - rawMinimum) * 0.18, Math.abs(rawMaximum) * 0.02, 0.5); + const minimum = rawMinimum - padding; + const maximum = rawMaximum + padding; + const scaleY = (value: number): number => + PAD_Y + ((maximum - value) / Math.max(maximum - minimum, 0.0001)) * (HEIGHT - PAD_Y * 2); + const coordinates = points.map((point, index) => ({ + x: + points.length === 1 + ? WIDTH / 2 + : PAD_X + (index / (points.length - 1)) * (WIDTH - PAD_X * 2), + y: scaleY(point.value), + })); + return { + coordinates, + path: createPath(coordinates), + rangeY: + referenceRange === null + ? null + : { + top: scaleY(referenceRange.maximum), + bottom: scaleY(referenceRange.minimum), + }, + }; + }, [points, referenceRange]); + + if (model === null || points.length < 2) { + return ( + + Tendance en construction + + Encore {Math.max(0, 2 - points.length)} mesure comparable pour tracer une évolution. + + + ); + } + + const first = points[0]!; + const last = points[points.length - 1]!; + const firstCoordinate = model.coordinates[0]!; + const lastCoordinate = model.coordinates[model.coordinates.length - 1]!; + const valueLabel = `${last.value.toFixed(digits)} ${unit}`; + + return ( + + + {model.rangeY === null ? null : ( + + )} + + + + + {model.coordinates.map((coordinate, index) => ( + + ))} + + + {dateLabel(first.measuredAt)} + {valueLabel} + {dateLabel(last.measuredAt)} + + + {points.length} mesures réelles{referenceRange === null ? '' : ' · zone pâle : repère personnel'} + + + ); +} + +const styles = StyleSheet.create({ + empty: { + minHeight: 96, + justifyContent: 'center', + paddingVertical: spacing.md, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + }, + emptyTitle: {color: colors.text, fontSize: 13, fontWeight: '700'}, + emptyText: {color: colors.muted, fontSize: 11, lineHeight: 16, marginTop: 3}, + labels: { + marginTop: -2, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + date: {color: colors.muted, fontSize: 10}, + lastValue: {fontSize: 11, fontWeight: '800', fontVariant: ['tabular-nums']}, + caption: {color: colors.muted, fontSize: 9, marginTop: spacing.xs}, +}); diff --git a/src/features/ppg/components/PPGResultSummary.tsx b/src/features/ppg/components/PPGResultSummary.tsx new file mode 100644 index 0000000..4a2117b --- /dev/null +++ b/src/features/ppg/components/PPGResultSummary.tsx @@ -0,0 +1,280 @@ +import React from 'react'; +import {Pressable, StyleSheet, Text, View} from 'react-native'; +import {Clock, Play, TrendUp, Wind} from 'phosphor-react-native'; + +import {colors} from '../../../theme'; +import type {HapticRegulationProtocol} from '../../cardiacMirror/regulationProtocols'; +import type {PPGProcessingResult} from '../measurement/measurementTypes'; +import type {PotentialStressResult} from '../stress/stressTypes'; + +interface PPGResultSummaryProps { + cameraResult: PPGProcessingResult; + potentialStress: PotentialStressResult; + recommendedProtocol: HapticRegulationProtocol; + onStartRegulation: () => void; + onDone: () => void; +} + +const clamp = (value: number, minimum: number, maximum: number): number => + Math.min(Math.max(value, minimum), maximum); + +export function PPGResultSummary({ + cameraResult, + potentialStress, + recommendedProtocol, + onStartRegulation, + onDone, +}: PPGResultSummaryProps): React.JSX.Element { + const score = Math.round( + clamp(100 - (potentialStress.index ?? 50), 0, 100), + ); + const qualityPercent = Math.round(clamp(cameraResult.quality.score, 0, 1) * 100); + const bpm = cameraResult.bpm.bpm === null ? '—' : Math.round(cameraResult.bpm.bpm); + const rmssd = + cameraResult.prv.reliable && cameraResult.prv.estimatedRmssdMs !== null + ? Math.round(cameraResult.prv.estimatedRmssdMs) + : '—'; + const balanceLabel = + score >= 62 ? 'Équilibre favorable' : score >= 42 ? 'Équilibre intermédiaire' : 'Activation élevée'; + const cursorPosition = clamp(score, 4, 94); + + return ( + + + VOTRE ÉTAT ACTUEL + {score} + + + {balanceLabel} + + + + + + + SYMPATHIQUE + Stress / Action + + + PARASYMPATHIQUE + Récupération + + + + + + + + Votre système nerveux montre une excellente capacité{`\n`}de régulation. + + + + + + + = 75 ? 'Bonne' : 'Moyenne'} status={`${qualityPercent}%`} accent /> + + + + + RECOMMANDATION + {recommendedProtocol.title} + + + + {Math.round(recommendedProtocol.durationSeconds / 60)} min + + + + + + + + + + [styles.launchButton, pressed && styles.pressed]}> + + LANCER LA SÉANCE + + + TERMINER + + + + ); +} + +function Metric({ + label, + value, + status, + accent = false, +}: { + label: string; + value: string; + status: string; + accent?: boolean; +}): React.JSX.Element { + return ( + + {label} + {value} + {status} + + ); +} + +const styles = StyleSheet.create({ + summary: { + alignSelf: 'stretch', + minHeight: 850, + marginHorizontal: -24, + paddingHorizontal: 20, + paddingTop: 16, + }, + hero: {height: 177, alignItems: 'center'}, + kicker: { + color: colors.accent, + fontSize: 10, + lineHeight: 14, + fontWeight: '800', + letterSpacing: 1.3, + }, + score: { + color: '#DEE6F2', + fontSize: 62, + lineHeight: 80, + fontWeight: '200', + letterSpacing: -2.5, + fontVariant: ['tabular-nums'], + marginTop: 20, + }, + balanceRow: {height: 22, flexDirection: 'row', alignItems: 'center', gap: 8}, + balanceText: {color: '#D0D8E4', fontSize: 17, fontWeight: '700'}, + axisCard: { + height: 152, + borderRadius: 13, + borderWidth: 1, + borderColor: '#17466F', + backgroundColor: '#07192B', + paddingHorizontal: 17, + paddingTop: 17, + }, + axisLabels: {height: 30, flexDirection: 'row', justifyContent: 'space-between'}, + axisRight: {alignItems: 'flex-end'}, + axisEyebrow: {color: '#8F9AA9', fontSize: 10, lineHeight: 14, letterSpacing: 1.2}, + axisValue: {color: '#D9E2EE', fontSize: 12, lineHeight: 16}, + axisRecovery: {color: colors.accent, fontSize: 12, lineHeight: 16}, + axisTrack: { + height: 8, + borderRadius: 4, + marginTop: 24, + backgroundColor: '#62D4FF', + borderLeftWidth: 98, + borderLeftColor: '#F25E68', + }, + centerDivider: { + position: 'absolute', + left: '50%', + top: -4, + width: 1, + height: 16, + backgroundColor: '#72808F', + }, + axisCursor: { + position: 'absolute', + top: -8, + width: 24, + height: 24, + marginLeft: -12, + borderRadius: 12, + borderWidth: 3, + borderColor: '#C7D7E9', + backgroundColor: '#132236', + }, + axisInterpretation: { + color: '#AAB6C4', + fontSize: 12, + lineHeight: 16, + fontStyle: 'italic', + textAlign: 'center', + marginTop: 22, + }, + metricsRow: {height: 94, flexDirection: 'row', gap: 13, marginTop: 24}, + metricCard: { + flex: 1, + borderRadius: 13, + borderWidth: 1, + borderColor: '#17466F', + backgroundColor: '#07192B', + alignItems: 'center', + paddingTop: 17, + }, + metricLabel: {color: '#B7C1CE', fontSize: 10, lineHeight: 14, letterSpacing: 1.2}, + metricValue: {color: '#D9E2EE', fontSize: 17, lineHeight: 22, fontWeight: '700'}, + metricValueAccent: {color: colors.accent}, + metricStatus: {color: '#8C98A7', fontSize: 9, fontWeight: '800', marginTop: 5}, + metricStatusAccent: {color: colors.accent}, + recommendationCard: { + height: 108, + marginTop: 24, + borderRadius: 13, + borderWidth: 1, + borderColor: '#17466F', + backgroundColor: '#07192B', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 17, + }, + recommendationCopy: {height: 74, justifyContent: 'center'}, + recommendationKicker: { + color: colors.accent, + fontSize: 10, + lineHeight: 14, + fontWeight: '800', + letterSpacing: 1.2, + }, + recommendationTitle: {color: '#DDE5F0', fontSize: 17, lineHeight: 22, fontWeight: '700', marginTop: 5}, + durationRow: {flexDirection: 'row', alignItems: 'center', gap: 8, marginTop: 5}, + durationText: {color: '#B6C1CF', fontSize: 12}, + windBadge: { + width: 64, + height: 64, + borderRadius: 32, + borderWidth: 1, + borderColor: '#2B688C', + backgroundColor: '#0A263A', + alignItems: 'center', + justifyContent: 'center', + }, + actions: {height: 112, marginTop: 24}, + launchButton: { + height: 52, + borderRadius: 13, + backgroundColor: '#75D3FA', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + shadowColor: colors.accent, + shadowOpacity: 0.25, + shadowRadius: 14, + shadowOffset: {width: 0, height: 8}, + }, + launchText: {color: '#07304A', fontSize: 11, fontWeight: '800'}, + doneButton: { + height: 44, + borderRadius: 13, + marginTop: 8, + borderWidth: 1, + borderColor: '#122A3D', + alignItems: 'center', + justifyContent: 'center', + }, + doneText: {color: '#C4CEDB', fontSize: 11}, + pressed: {opacity: 0.76, transform: [{scale: 0.99}]}, +}); diff --git a/src/features/ppg/components/PotentialStressGauge.tsx b/src/features/ppg/components/PotentialStressGauge.tsx new file mode 100644 index 0000000..51d7448 --- /dev/null +++ b/src/features/ppg/components/PotentialStressGauge.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import {colors, radii, spacing} from '../../../theme'; + +interface PotentialStressGaugeProps { + index: number | null; + label: string; +} + +export function PotentialStressGauge({ + index, + label, +}: PotentialStressGaugeProps): React.JSX.Element { + const normalized = index === null ? 0 : Math.min(Math.max(index, 0), 100); + return ( + + + {index === null ? '—' : index} + {index !== null ? /100 : null} + + {label} + + + + + Habituel + Activation potentielle + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.surface, + borderRadius: radii.lg, + borderWidth: 1, + borderColor: colors.border, + padding: spacing.lg, + }, + valueRow: { + flexDirection: 'row', + alignItems: 'baseline', + }, + value: { + color: colors.text, + fontSize: 54, + fontWeight: '800', + letterSpacing: -2, + fontVariant: ['tabular-nums'], + }, + outOf: { + color: colors.muted, + fontSize: 16, + marginLeft: spacing.xs, + }, + label: { + color: colors.text, + fontSize: 16, + lineHeight: 22, + marginTop: spacing.xs, + }, + track: { + marginTop: spacing.lg, + height: 9, + borderRadius: radii.pill, + backgroundColor: colors.border, + overflow: 'hidden', + }, + fill: { + height: '100%', + backgroundColor: colors.accent, + }, + scale: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: spacing.xs, + }, + scaleText: { + color: colors.muted, + fontSize: 10, + }, +}); diff --git a/src/features/ppg/components/PrimaryButton.tsx b/src/features/ppg/components/PrimaryButton.tsx new file mode 100644 index 0000000..45c585e --- /dev/null +++ b/src/features/ppg/components/PrimaryButton.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { + ActivityIndicator, + Pressable, + StyleSheet, + Text, + type ViewStyle, +} from 'react-native'; + +import {colors, radii, spacing} from '../../../theme'; + +interface PrimaryButtonProps { + label: string; + onPress: () => void; + disabled?: boolean; + loading?: boolean; + variant?: 'primary' | 'secondary' | 'ghost'; + style?: ViewStyle; +} + +export function PrimaryButton({ + label, + onPress, + disabled = false, + loading = false, + variant = 'primary', + style, +}: PrimaryButtonProps): React.JSX.Element { + return ( + [ + styles.base, + styles[variant], + (disabled || loading) && styles.disabled, + pressed && styles.pressed, + style, + ]}> + {loading ? ( + + ) : ( + {label} + )} + + ); +} + +const styles = StyleSheet.create({ + base: { + minHeight: 54, + paddingHorizontal: spacing.lg, + borderRadius: radii.md, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + }, + primary: { + backgroundColor: colors.accent, + borderColor: colors.accent, + }, + secondary: { + backgroundColor: colors.surfaceElevated, + borderColor: colors.border, + }, + ghost: { + backgroundColor: 'transparent', + borderColor: 'transparent', + }, + label: { + fontSize: 16, + lineHeight: 20, + fontWeight: '700', + }, + primaryLabel: { + color: colors.background, + }, + secondaryLabel: { + color: colors.text, + }, + ghostLabel: { + color: colors.muted, + }, + disabled: { + opacity: 0.45, + }, + pressed: { + opacity: 0.78, + transform: [{scale: 0.99}], + }, +}); diff --git a/src/features/ppg/components/SignalQualityBadge.tsx b/src/features/ppg/components/SignalQualityBadge.tsx new file mode 100644 index 0000000..225a0fa --- /dev/null +++ b/src/features/ppg/components/SignalQualityBadge.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import {StyleSheet, Text, View} from 'react-native'; + +import {colors, radii, spacing} from '../../../theme'; +import type {SignalQualityLevel} from '../measurement/measurementTypes'; + +interface SignalQualityBadgeProps { + level: SignalQualityLevel; + score: number; +} + +const labels: Record = { + good: 'Bonne qualité', + medium: 'Qualité moyenne', + poor: 'Qualité faible', + invalid: 'Signal invalide', +}; + +const levelColor: Record = { + good: colors.good, + medium: colors.medium, + poor: colors.poor, + invalid: colors.poor, +}; + +export function SignalQualityBadge({ + level, + score, +}: SignalQualityBadgeProps): React.JSX.Element { + return ( + + + {labels[level]} + {Math.round(score * 100)} % + + ); +} + +const styles = StyleSheet.create({ + badge: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'flex-start', + borderWidth: 1, + borderRadius: radii.pill, + paddingVertical: spacing.xs, + paddingHorizontal: spacing.sm, + }, + dot: { + width: 7, + height: 7, + borderRadius: 4, + marginRight: 7, + }, + label: { + color: colors.text, + fontSize: 13, + fontWeight: '600', + }, + score: { + color: colors.muted, + fontSize: 12, + marginLeft: spacing.xs, + }, +}); diff --git a/src/features/ppg/health/healthContextEvaluator.ts b/src/features/ppg/health/healthContextEvaluator.ts new file mode 100644 index 0000000..123dbd2 --- /dev/null +++ b/src/features/ppg/health/healthContextEvaluator.ts @@ -0,0 +1,286 @@ +import type { + HealthContextEvaluation, + HealthContextIssue, + HealthKitContext, + HealthMetricEvaluation, + HealthMetricIssue, + HealthMetricSample, + HealthMetricType, +} from './healthTypes'; + +export interface HealthMetricRecencyThreshold { + freshSeconds: number; + usableSeconds: number; +} + +export interface HealthContextEvaluatorConfiguration { + futureToleranceSeconds: number; + recencyByType: Readonly< + Record + >; +} + +const MINUTE = 60; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +export const DEFAULT_HEALTH_CONTEXT_EVALUATOR_CONFIGURATION: Readonly = + Object.freeze({ + futureToleranceSeconds: 5 * MINUTE, + recencyByType: Object.freeze({ + heartRate: Object.freeze({ + freshSeconds: 15 * MINUTE, + usableSeconds: 24 * HOUR, + }), + restingHeartRate: Object.freeze({ + freshSeconds: 2 * DAY, + usableSeconds: 7 * DAY, + }), + hrvSdnn: Object.freeze({ + freshSeconds: 24 * HOUR, + usableSeconds: 7 * DAY, + }), + }), + }); + +const clamp01 = (value: number): number => + Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0; + +const invalidEvaluation = ( + sample: HealthMetricSample, + issues: readonly HealthMetricIssue[], + relevance: HealthMetricEvaluation['relevance'] = 'invalid', +): HealthMetricEvaluation => ({ + sample, + ageSeconds: null, + relevance, + confidence: 0, + usableAsContext: false, + canRepresentCurrentMeasurement: false, + issues, +}); + +export const evaluateHealthMetricSample = ( + sample: HealthMetricSample, + now: Date = new Date(), + configuration: HealthContextEvaluatorConfiguration = + DEFAULT_HEALTH_CONTEXT_EVALUATOR_CONFIGURATION, +): HealthMetricEvaluation => { + if (!Number.isFinite(sample.value)) { + return invalidEvaluation(sample, ['nonFiniteValue']); + } + if (sample.value <= 0) { + return invalidEvaluation(sample, ['nonPositiveValue']); + } + + const startMilliseconds = Date.parse(sample.startDate); + const endMilliseconds = Date.parse(sample.endDate); + const nowMilliseconds = now.getTime(); + if ( + !Number.isFinite(startMilliseconds) || + !Number.isFinite(endMilliseconds) || + !Number.isFinite(nowMilliseconds) + ) { + return invalidEvaluation(sample, ['invalidDate']); + } + if (endMilliseconds < startMilliseconds) { + return invalidEvaluation(sample, ['endBeforeStart']); + } + + const rawAgeSeconds = (nowMilliseconds - endMilliseconds) / 1_000; + if (rawAgeSeconds < -configuration.futureToleranceSeconds) { + return invalidEvaluation(sample, ['futureDated'], 'futureDated'); + } + + const ageSeconds = Math.max(rawAgeSeconds, 0); + const threshold = configuration.recencyByType[sample.type]; + if (ageSeconds <= threshold.freshSeconds) { + const freshnessRatio = + threshold.freshSeconds === 0 + ? 0 + : ageSeconds / threshold.freshSeconds; + return { + sample, + ageSeconds, + relevance: 'freshContext', + confidence: clamp01(1 - 0.25 * freshnessRatio), + usableAsContext: true, + canRepresentCurrentMeasurement: false, + issues: [], + }; + } + + if (ageSeconds <= threshold.usableSeconds) { + const recencyRange = threshold.usableSeconds - threshold.freshSeconds; + const recencyRatio = + recencyRange <= 0 + ? 1 + : (ageSeconds - threshold.freshSeconds) / recencyRange; + return { + sample, + ageSeconds, + relevance: 'recentContext', + confidence: clamp01(0.75 - 0.65 * recencyRatio), + usableAsContext: true, + canRepresentCurrentMeasurement: false, + issues: [], + }; + } + + return { + sample, + ageSeconds, + relevance: 'stale', + confidence: 0, + usableAsContext: false, + canRepresentCurrentMeasurement: false, + issues: ['stale'], + }; +}; + +const evaluateExpectedType = ( + sample: HealthMetricSample | null, + expectedType: HealthMetricType, + now: Date, + configuration: HealthContextEvaluatorConfiguration, +): HealthMetricEvaluation | null => { + if (sample === null) { + return null; + } + if (sample.type !== expectedType) { + return invalidEvaluation(sample, ['unexpectedMetricType']); + } + return evaluateHealthMetricSample(sample, now, configuration); +}; + +const averageConfidence = ( + evaluations: readonly HealthMetricEvaluation[], +): number => { + const usable = evaluations.filter(evaluation => evaluation.usableAsContext); + if (usable.length === 0) { + return 0; + } + return ( + usable.reduce((sum, evaluation) => sum + evaluation.confidence, 0) / + usable.length + ); +}; + +export const evaluateHealthKitContext = ( + context: HealthKitContext, + now: Date = new Date(), + configuration: HealthContextEvaluatorConfiguration = + DEFAULT_HEALTH_CONTEXT_EVALUATOR_CONFIGURATION, +): HealthContextEvaluation => { + const issues = new Set(); + if (!context.available) { + issues.add('healthKitUnavailable'); + } + if (context.available && !context.authorizationRequestSettled) { + issues.add('healthKitAuthorizationNotRequested'); + } + + const latestRestingHeartRate = evaluateExpectedType( + context.latestRestingHeartRate, + 'restingHeartRate', + now, + configuration, + ); + const latestHrvSdnn = evaluateExpectedType( + context.latestHrvSdnn, + 'hrvSdnn', + now, + configuration, + ); + const recentHeartRateSamples = context.recentHeartRateSamples.map(sample => + evaluateExpectedType(sample, 'heartRate', now, configuration), + ).filter((evaluation): evaluation is HealthMetricEvaluation => evaluation !== null); + + if ( + latestRestingHeartRate !== null && + latestRestingHeartRate.relevance === 'stale' + ) { + issues.add('staleRestingHeartRate'); + } + if (latestHrvSdnn !== null && latestHrvSdnn.relevance === 'stale') { + issues.add('staleHrvSdnn'); + } + if ( + recentHeartRateSamples.length > 0 && + recentHeartRateSamples.every(sample => !sample.usableAsContext) + ) { + issues.add('staleHeartRateHistory'); + } + + const allEvaluations = [ + ...(latestRestingHeartRate === null ? [] : [latestRestingHeartRate]), + ...(latestHrvSdnn === null ? [] : [latestHrvSdnn]), + ...recentHeartRateSamples, + ]; + const usableEvaluations = allEvaluations.filter( + evaluation => evaluation.usableAsContext, + ); + + const weightedCategories: {weight: number; confidence: number}[] = []; + if (latestRestingHeartRate?.usableAsContext) { + weightedCategories.push({ + weight: 0.35, + confidence: latestRestingHeartRate.confidence, + }); + } + if (latestHrvSdnn?.usableAsContext) { + weightedCategories.push({ + weight: 0.35, + confidence: latestHrvSdnn.confidence, + }); + } + if (recentHeartRateSamples.some(sample => sample.usableAsContext)) { + weightedCategories.push({ + weight: 0.3, + confidence: averageConfidence(recentHeartRateSamples), + }); + } + + const availableWeight = weightedCategories.reduce( + (sum, category) => sum + category.weight, + 0, + ); + const calculatedConfidence = + availableWeight === 0 + ? 0 + : weightedCategories.reduce( + (sum, category) => sum + category.weight * category.confidence, + 0, + ) / availableWeight; + const permissionMultiplier = + context.available && context.authorizationRequestSettled ? 1 : 0; + const contextConfidence = + clamp01(calculatedConfidence) * + clamp01(context.contextConfidence) * + permissionMultiplier; + + if (usableEvaluations.length === 0) { + issues.add('noUsableHealthData'); + } + + const sourceBundleIdentifiers = [ + ...new Set( + usableEvaluations + .map(evaluation => evaluation.sample.sourceBundleIdentifier) + .filter((source): source is string => source !== null), + ), + ].sort(); + + return { + latestRestingHeartRate, + latestHrvSdnn, + recentHeartRateSamples, + contextConfidence, + usableSampleCount: usableEvaluations.length, + sourceBundleIdentifiers, + hasUsableContext: + usableEvaluations.length > 0 && contextConfidence > 0, + healthKitUsedForScore: false, + issues: [...issues], + }; +}; diff --git a/src/features/ppg/health/healthKitService.ts b/src/features/ppg/health/healthKitService.ts new file mode 100644 index 0000000..415314b --- /dev/null +++ b/src/features/ppg/health/healthKitService.ts @@ -0,0 +1,55 @@ +import { + haptikaHealthKit, + type NativeHeartbeatSeriesSummary, + type NativeHealthMetricSample, +} from 'react-native-haptika-ppg'; + +import type {HealthKitContext, HealthMetricSample} from './healthTypes'; + +const mapSample = (sample: NativeHealthMetricSample): HealthMetricSample => ({ + type: sample.type, + value: sample.value, + unit: sample.unit, + startDate: sample.startDate, + endDate: sample.endDate, + sourceName: sample.sourceName ?? null, + sourceBundleIdentifier: sample.sourceBundleIdentifier ?? null, + deviceName: sample.deviceName ?? null, +}); + +export const healthKitService = { + isAvailable(): boolean { + return haptikaHealthKit.isAvailable(); + }, + + requestReadAuthorization(): Promise { + return haptikaHealthKit.requestReadAuthorization(); + }, + + async fetchContext(lookbackDays = 14): Promise { + const context = await haptikaHealthKit.fetchContext(lookbackDays); + return { + available: context.available, + // The native field retains its generated ABI name, but represents only + // HKAuthorizationRequestStatus.unnecessary — never proof of read access. + authorizationRequestSettled: context.authorized, + latestRestingHeartRate: + context.latestRestingHeartRate === undefined + ? null + : mapSample(context.latestRestingHeartRate), + latestHrvSdnn: + context.latestHrvSdnn === undefined + ? null + : mapSample(context.latestHrvSdnn), + recentHeartRateSamples: context.recentHeartRateSamples.map(mapSample), + contextConfidence: context.contextConfidence, + fetchedAt: context.fetchedAt, + }; + }, + + fetchLatestHeartbeatSeries(): Promise< + NativeHeartbeatSeriesSummary | undefined + > { + return haptikaHealthKit.fetchLatestHeartbeatSeries(); + }, +}; diff --git a/src/features/ppg/health/healthTypes.ts b/src/features/ppg/health/healthTypes.ts new file mode 100644 index 0000000..3a3c9dd --- /dev/null +++ b/src/features/ppg/health/healthTypes.ts @@ -0,0 +1,70 @@ +export type HealthMetricType = 'heartRate' | 'restingHeartRate' | 'hrvSdnn'; + +export interface HealthMetricSample { + type: HealthMetricType; + value: number; + unit: string; + startDate: string; + endDate: string; + sourceName: string | null; + sourceBundleIdentifier: string | null; + deviceName: string | null; +} + +export interface HealthKitContext { + available: boolean; + /** The permission sheet no longer needs presenting; Apple never reveals read approval. */ + authorizationRequestSettled: boolean; + latestRestingHeartRate: HealthMetricSample | null; + latestHrvSdnn: HealthMetricSample | null; + recentHeartRateSamples: readonly HealthMetricSample[]; + contextConfidence: number; + fetchedAt: string; +} + +export type HealthMetricRelevance = + | 'freshContext' + | 'recentContext' + | 'stale' + | 'futureDated' + | 'invalid'; + +export type HealthMetricIssue = + | 'nonFiniteValue' + | 'nonPositiveValue' + | 'invalidDate' + | 'endBeforeStart' + | 'futureDated' + | 'stale' + | 'unexpectedMetricType'; + +export interface HealthMetricEvaluation { + sample: HealthMetricSample; + ageSeconds: number | null; + relevance: HealthMetricRelevance; + confidence: number; + usableAsContext: boolean; + /** HealthKit context must never impersonate the current camera measurement. */ + canRepresentCurrentMeasurement: false; + issues: readonly HealthMetricIssue[]; +} + +export type HealthContextIssue = + | 'healthKitUnavailable' + | 'healthKitAuthorizationNotRequested' + | 'noUsableHealthData' + | 'staleRestingHeartRate' + | 'staleHrvSdnn' + | 'staleHeartRateHistory'; + +export interface HealthContextEvaluation { + latestRestingHeartRate: HealthMetricEvaluation | null; + latestHrvSdnn: HealthMetricEvaluation | null; + recentHeartRateSamples: readonly HealthMetricEvaluation[]; + contextConfidence: number; + usableSampleCount: number; + sourceBundleIdentifiers: readonly string[]; + hasUsableContext: boolean; + healthKitUsedForScore: false; + issues: readonly HealthContextIssue[]; +} diff --git a/src/features/ppg/history/historyAnalytics.ts b/src/features/ppg/history/historyAnalytics.ts new file mode 100644 index 0000000..b46744d --- /dev/null +++ b/src/features/ppg/history/historyAnalytics.ts @@ -0,0 +1,78 @@ +import type {StoredMeasurement} from '../storage/storageTypes'; + +export interface DailyTrendPoint { + day: string; + bpm: number | null; + rmssdMs: number | null; + quality: number; + count: number; +} + +const average = (values: readonly number[]): number | null => + values.length === 0 + ? null + : values.reduce((sum, value) => sum + value, 0) / values.length; + +export function buildDailyTrends( + measurements: readonly StoredMeasurement[], + days = 14, + now = Date.now(), +): DailyTrendPoint[] { + const cutoff = now - days * 86_400_000; + const groups = new Map(); + measurements + .filter(measurement => + measurement.cameraResult.valid && Date.parse(measurement.measuredAt) >= cutoff, + ) + .forEach(measurement => { + const day = measurement.measuredAt.slice(0, 10); + groups.set(day, [...(groups.get(day) ?? []), measurement]); + }); + return [...groups.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([day, values]) => { + const bpm = values + .map(value => value.cameraResult.bpm.bpm) + .filter((value): value is number => value !== null && Number.isFinite(value)); + const rmssd = values + .map(value => value.cameraResult.prv.estimatedRmssdMs) + .filter((value): value is number => value !== null && Number.isFinite(value)); + return { + day, + bpm: average(bpm), + rmssdMs: average(rmssd), + quality: average(values.map(value => value.cameraResult.quality.score)) ?? 0, + count: values.length, + }; + }); +} + +export interface FeelingCorrelationPoint { + feeling: number; + averageBpm: number | null; + averageRmssdMs: number | null; + count: number; +} + +export function buildFeelingCorrelation( + measurements: readonly StoredMeasurement[], +): FeelingCorrelationPoint[] { + return [1, 2, 3, 4, 5].flatMap(feeling => { + const matches = measurements.filter( + measurement => measurement.selfReport?.feeling === feeling, + ); + if (matches.length === 0) return []; + const bpm = matches + .map(value => value.cameraResult.bpm.bpm) + .filter((value): value is number => value !== null && Number.isFinite(value)); + const rmssd = matches + .map(value => value.cameraResult.prv.estimatedRmssdMs) + .filter((value): value is number => value !== null && Number.isFinite(value)); + return [{ + feeling, + averageBpm: average(bpm), + averageRmssdMs: average(rmssd), + count: matches.length, + }]; + }); +} diff --git a/src/features/ppg/insights/metricInsights.ts b/src/features/ppg/insights/metricInsights.ts new file mode 100644 index 0000000..21bc63d --- /dev/null +++ b/src/features/ppg/insights/metricInsights.ts @@ -0,0 +1,250 @@ +import type {PPGProcessingResult} from '../measurement/measurementTypes'; +import type {StoredMeasurement} from '../storage/storageTypes'; + +export type MetricInsightKey = + | 'bpm' + | 'meanIpi' + | 'rmssd' + | 'sdnn' + | 'pnn50' + | 'rangeIpi' + | 'modeIpi' + | 'amo50' + | 'cv'; + +export type MetricInsightTone = 'good' | 'neutral' | 'attention' | 'unavailable'; + +export interface MetricTrendPoint { + id: string; + measuredAt: string; + value: number; +} + +export interface MetricInsight { + key: MetricInsightKey; + label: string; + shortLabel: string; + value: number | null; + unit: string; + digits: number; + status: string; + tone: MetricInsightTone; + description: string; + methodology: string; + trend: readonly MetricTrendPoint[]; + referenceRange: {minimum: number; maximum: number} | null; +} + +interface MetricDefinition { + key: MetricInsightKey; + label: string; + shortLabel: string; + unit: string; + digits: number; + description: string; + methodology: string; + value: (result: PPGProcessingResult) => number | null; + requiresReliablePrv?: boolean; +} + +const finiteOrNull = (value: number | null | undefined): number | null => + value !== null && value !== undefined && Number.isFinite(value) ? value : null; + +const definitions: readonly MetricDefinition[] = [ + { + key: 'bpm', + label: 'Pouls moyen', + shortLabel: 'Pouls', + unit: 'BPM', + digits: 0, + description: + 'Le nombre moyen de pulsations détectées par minute pendant cette mesure.', + methodology: 'PPG caméra iPhone', + value: result => finiteOrNull(result.bpm.bpm), + }, + { + key: 'meanIpi', + label: 'Intervalle pulsatile moyen', + shortLabel: 'RR moyen', + unit: 'ms', + digits: 0, + description: + 'Le temps moyen entre deux pulsations détectées. Il évolue à l’inverse du pouls.', + methodology: 'Moyenne des IPI valides', + value: result => finiteOrNull(result.prv.meanIpiMs), + requiresReliablePrv: true, + }, + { + key: 'rmssd', + label: 'Variabilité pulsatile courte', + shortLabel: 'RMSSD', + unit: 'ms', + digits: 0, + description: + 'Décrit les variations rapides entre intervalles successifs. La comparaison la plus utile est avec vos propres mesures au repos.', + methodology: 'PRV caméra expérimentale · pas une HRV ECG', + value: result => finiteOrNull(result.prv.estimatedRmssdMs), + requiresReliablePrv: true, + }, + { + key: 'sdnn', + label: 'Variabilité pulsatile globale', + shortLabel: 'SDNN', + unit: 'ms', + digits: 0, + description: + 'Résume la dispersion des intervalles valides sur toute la durée de la mesure.', + methodology: 'PRV caméra expérimentale · pas une HRV ECG', + value: result => finiteOrNull(result.prv.estimatedSdnnMs), + requiresReliablePrv: true, + }, + { + key: 'pnn50', + label: 'Intervalles très variables', + shortLabel: 'pNN50', + unit: '%', + digits: 0, + description: + 'Part des intervalles successifs dont l’écart dépasse 50 ms. Cette valeur dépend fortement de la qualité des pics.', + methodology: 'Calcul sur les IPI valides', + value: result => finiteOrNull(result.prv.pnn50Percent), + requiresReliablePrv: true, + }, + { + key: 'rangeIpi', + label: 'Étendue des intervalles', + shortLabel: 'MxDMn', + unit: 's', + digits: 2, + description: + 'Écart entre l’intervalle le plus long et le plus court retenus pendant la mesure.', + methodology: 'Maximum moins minimum des IPI valides', + value: result => { + const value = finiteOrNull(result.prv.rangeIpiMs); + return value === null ? null : value / 1_000; + }, + requiresReliablePrv: true, + }, + { + key: 'modeIpi', + label: 'Intervalle le plus fréquent', + shortLabel: 'Moda', + unit: 'ms', + digits: 0, + description: + 'La zone d’intervalle la plus souvent observée parmi les pulsations retenues.', + methodology: 'Mode des IPI par classes de 50 ms', + value: result => finiteOrNull(result.prv.modeIpiMs), + requiresReliablePrv: true, + }, + { + key: 'amo50', + label: 'Concentration autour du mode', + shortLabel: 'AMo50', + unit: '%', + digits: 0, + description: + 'Part des intervalles regroupés autour de la valeur la plus fréquente.', + methodology: 'Amplitude du mode par classes de 50 ms', + value: result => finiteOrNull(result.prv.amo50Percent), + requiresReliablePrv: true, + }, + { + key: 'cv', + label: 'Dispersion relative', + shortLabel: 'CV', + unit: '%', + digits: 1, + description: + 'Rapporte la dispersion des intervalles à leur moyenne pour faciliter les comparaisons entre mesures.', + methodology: 'Coefficient de variation des IPI valides', + value: result => { + const value = finiteOrNull(result.prv.coefficientOfVariation); + return value === null ? null : value * 100; + }, + requiresReliablePrv: true, + }, +]; + +const median = (values: readonly number[]): number => { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1]! + sorted[middle]!) / 2 + : sorted[middle]!; +}; + +const personalRange = ( + values: readonly number[], +): {minimum: number; maximum: number} | null => { + if (values.length < 5) return null; + const center = median(values); + const mad = median(values.map(value => Math.abs(value - center))); + const halfWidth = Math.max(mad * 2.2239, Math.abs(center) * 0.04, 0.5); + return {minimum: center - halfWidth, maximum: center + halfWidth}; +}; + +const interpretation = ( + value: number | null, + range: {minimum: number; maximum: number} | null, +): Pick => { + if (value === null) { + return {status: 'Signal insuffisant', tone: 'unavailable'}; + } + if (range === null) { + return {status: 'Repère en construction', tone: 'neutral'}; + } + if (value < range.minimum) { + return {status: 'Sous votre repère habituel', tone: 'attention'}; + } + if (value > range.maximum) { + return {status: 'Au-dessus de votre repère habituel', tone: 'attention'}; + } + return {status: 'Dans votre repère habituel', tone: 'good'}; +}; + +export const buildMetricInsights = ( + currentResult: PPGProcessingResult, + measurements: readonly StoredMeasurement[], +): readonly MetricInsight[] => + definitions.map(definition => { + const currentValue = + definition.requiresReliablePrv && !currentResult.prv.reliable + ? null + : definition.value(currentResult); + const trend = measurements + .filter( + measurement => + !definition.requiresReliablePrv || measurement.cameraResult.prv.reliable, + ) + .map(measurement => ({ + id: measurement.id, + measuredAt: measurement.measuredAt, + value: definition.value(measurement.cameraResult), + })) + .filter( + (point): point is MetricTrendPoint => + point.value !== null && Number.isFinite(point.value), + ) + .sort( + (left, right) => Date.parse(left.measuredAt) - Date.parse(right.measuredAt), + ) + .slice(-30); + const comparableValues = trend.map(point => point.value); + const referenceRange = personalRange(comparableValues); + const {status, tone} = interpretation(currentValue, referenceRange); + return { + key: definition.key, + label: definition.label, + shortLabel: definition.shortLabel, + value: currentValue, + unit: definition.unit, + digits: definition.digits, + status, + tone, + description: definition.description, + methodology: definition.methodology, + trend, + referenceRange, + }; + }); diff --git a/src/features/ppg/measurement/measurementConfiguration.ts b/src/features/ppg/measurement/measurementConfiguration.ts new file mode 100644 index 0000000..07d5b82 --- /dev/null +++ b/src/features/ppg/measurement/measurementConfiguration.ts @@ -0,0 +1,194 @@ +export const PPG_ALGORITHM_VERSION = 'HAPTIKA_PPG_FRONT_V1.0.0'; + +/** + * Publication gates shared conceptually with native `PPGPublicationThresholds`. + * BPM is a mean-rate output, PRV requires stricter beat timing, and the potential + * index remains a separate non-medical product calculation based on a baseline. + */ +export const PPG_PUBLICATION_THRESHOLDS = Object.freeze({ + bpmQuality: 0.45, + bpmConfidence: 0.4, + prvQuality: 0.8, + prvBpmConfidence: 0.7, + potentialIndexQuality: 0.65, + potentialIndexMinimumIntervals: 20, +}); + +export interface MeasurementConfiguration { + targetSampleRate: number; + minimumFrequencyHz: number; + maximumFrequencyHz: number; + stabilizationSeconds: number; + minimumMeasurementSeconds: number; + measurementSeconds: number; + targetValidBeatCount: number; + emitEveryFrames: number; + minimumContactConfidence: number; + minimumSignalQuality: number; + goodSignalQuality: number; + maximumShortGapSeconds: number; + maximumDroppedFrameRatio: number; + maximumRejectedIntervalRatio: number; + minimumReliableIntervals: number; +} + +export const DEFAULT_MEASUREMENT_CONFIGURATION: Readonly = + Object.freeze({ + // Keep the processing timebase equal to the requested camera cadence. + // Interpolation cannot create optical information that the sensor did not + // capture; sub-frame peak timing is handled by native peak refinement. + targetSampleRate: 30, + minimumFrequencyHz: 0.7, + maximumFrequencyHz: 3.5, + stabilizationSeconds: 5, + minimumMeasurementSeconds: 45, + measurementSeconds: 120, + targetValidBeatCount: 45, + emitEveryFrames: 3, + minimumContactConfidence: 0.48, + minimumSignalQuality: PPG_PUBLICATION_THRESHOLDS.potentialIndexQuality, + goodSignalQuality: 0.8, + maximumShortGapSeconds: 0.25, + maximumDroppedFrameRatio: 0.1, + maximumRejectedIntervalRatio: 0.25, + minimumReliableIntervals: + PPG_PUBLICATION_THRESHOLDS.potentialIndexMinimumIntervals, + }); + +const assertFinite = (name: string, value: number): void => { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be finite.`); + } +}; + +const assertRatio = (name: string, value: number): void => { + assertFinite(name, value); + if (value < 0 || value > 1) { + throw new RangeError(`${name} must be between 0 and 1.`); + } +}; + +export const validateMeasurementConfiguration = ( + configuration: MeasurementConfiguration, +): MeasurementConfiguration => { + assertFinite('targetSampleRate', configuration.targetSampleRate); + if ( + configuration.targetSampleRate < 20 || + configuration.targetSampleRate > 120 + ) { + throw new RangeError('targetSampleRate must be between 20 and 120 Hz.'); + } + + assertFinite('minimumFrequencyHz', configuration.minimumFrequencyHz); + assertFinite('maximumFrequencyHz', configuration.maximumFrequencyHz); + if ( + configuration.minimumFrequencyHz <= 0 || + configuration.maximumFrequencyHz <= configuration.minimumFrequencyHz || + configuration.maximumFrequencyHz >= configuration.targetSampleRate / 2 + ) { + throw new RangeError( + 'The physiological band must be positive, ordered, and below Nyquist.', + ); + } + + assertFinite('stabilizationSeconds', configuration.stabilizationSeconds); + if ( + configuration.stabilizationSeconds < 0 || + configuration.stabilizationSeconds > 30 + ) { + throw new RangeError('stabilizationSeconds must be between 0 and 30.'); + } + + assertFinite('measurementSeconds', configuration.measurementSeconds); + if ( + configuration.measurementSeconds < 20 || + configuration.measurementSeconds > 120 + ) { + throw new RangeError('measurementSeconds must be between 20 and 120.'); + } + + assertFinite( + 'minimumMeasurementSeconds', + configuration.minimumMeasurementSeconds, + ); + if ( + configuration.minimumMeasurementSeconds < 20 || + configuration.minimumMeasurementSeconds > configuration.measurementSeconds + ) { + throw new RangeError( + 'minimumMeasurementSeconds must be between 20 and measurementSeconds.', + ); + } + + if ( + !Number.isInteger(configuration.targetValidBeatCount) || + configuration.targetValidBeatCount < 20 || + configuration.targetValidBeatCount > 300 + ) { + throw new RangeError( + 'targetValidBeatCount must be an integer between 20 and 300.', + ); + } + + if ( + !Number.isInteger(configuration.emitEveryFrames) || + configuration.emitEveryFrames < 1 + ) { + throw new RangeError('emitEveryFrames must be a positive integer.'); + } + + assertRatio( + 'minimumContactConfidence', + configuration.minimumContactConfidence, + ); + assertRatio('minimumSignalQuality', configuration.minimumSignalQuality); + assertRatio('goodSignalQuality', configuration.goodSignalQuality); + if (configuration.goodSignalQuality < configuration.minimumSignalQuality) { + throw new RangeError( + 'goodSignalQuality must be greater than or equal to minimumSignalQuality.', + ); + } + + assertFinite( + 'maximumShortGapSeconds', + configuration.maximumShortGapSeconds, + ); + if ( + configuration.maximumShortGapSeconds <= 0 || + configuration.maximumShortGapSeconds > 1 + ) { + throw new RangeError('maximumShortGapSeconds must be in (0, 1].'); + } + + assertRatio( + 'maximumDroppedFrameRatio', + configuration.maximumDroppedFrameRatio, + ); + assertRatio( + 'maximumRejectedIntervalRatio', + configuration.maximumRejectedIntervalRatio, + ); + + if ( + !Number.isInteger(configuration.minimumReliableIntervals) || + configuration.minimumReliableIntervals < 3 + ) { + throw new RangeError( + 'minimumReliableIntervals must be an integer greater than or equal to 3.', + ); + } + + return configuration; +}; + +export const createMeasurementConfiguration = ( + overrides: Partial = {}, +): Readonly => { + const configuration: MeasurementConfiguration = { + ...DEFAULT_MEASUREMENT_CONFIGURATION, + ...overrides, + }; + + validateMeasurementConfiguration(configuration); + return Object.freeze(configuration); +}; diff --git a/src/features/ppg/measurement/measurementCoordinator.ts b/src/features/ppg/measurement/measurementCoordinator.ts new file mode 100644 index 0000000..50a1987 --- /dev/null +++ b/src/features/ppg/measurement/measurementCoordinator.ts @@ -0,0 +1,104 @@ +import type {NativePPGProcessingResult} from 'react-native-haptika-ppg'; + +import type { + PPGProcessingResult, + SignalQualityIssue, + SignalQualityLevel, +} from './measurementTypes'; + +const KNOWN_ISSUES: ReadonlySet = new Set([ + 'contactInsufficient', + 'contactInterrupted', + 'durationInsufficient', + 'droppedFrames', + 'timestampJitter', + 'longGap', + 'saturation', + 'motionArtifacts', + 'lowPulsatileAmplitude', + 'lowSignalToNoiseRatio', + 'tooFewPeaks', + 'tooManyRejectedPeaks', + 'tooManyRejectedIntervals', + 'methodDisagreement', + 'channelInstability', + 'nonFiniteProcessingResult', +]); + +const finiteOrNull = (value: number | undefined): number | null => + value !== undefined && Number.isFinite(value) ? value : null; + +const qualityLevel = (level: string): SignalQualityLevel => { + if (level === 'good' || level === 'medium' || level === 'poor') { + return level; + } + return 'invalid'; +}; + +const qualityIssue = (issue: string): SignalQualityIssue => + KNOWN_ISSUES.has(issue as SignalQualityIssue) + ? (issue as SignalQualityIssue) + : 'nonFiniteProcessingResult'; + +export function mapNativeProcessingResult( + result: NativePPGProcessingResult, +): PPGProcessingResult { + return { + valid: result.valid, + invalidReason: result.invalidReason ?? null, + bpm: { + bpm: finiteOrNull(result.bpm.bpm), + temporalBpm: finiteOrNull(result.bpm.temporalBpm), + frequencyBpm: finiteOrNull(result.bpm.frequencyBpm), + methodAgreementBpm: finiteOrNull(result.bpm.methodAgreementBpm), + validPeakCount: Math.max(0, Math.round(result.bpm.validPeakCount)), + rejectedPeakCount: Math.max(0, Math.round(result.bpm.rejectedPeakCount)), + confidence: result.bpm.confidence, + selectedChannel: result.bpm.selectedChannel, + }, + prv: { + estimatedRmssdMs: finiteOrNull(result.prv.estimatedRmssdMs), + estimatedSdnnMs: finiteOrNull(result.prv.estimatedSdnnMs), + meanIpiMs: finiteOrNull(result.prv.meanIpiMs), + medianIpiMs: finiteOrNull(result.prv.medianIpiMs), + pnn50Percent: finiteOrNull(result.prv.pnn50Percent), + rangeIpiMs: finiteOrNull(result.prv.rangeIpiMs), + modeIpiMs: finiteOrNull(result.prv.modeIpiMs), + amo50Percent: finiteOrNull(result.prv.amo50Percent), + coefficientOfVariation: finiteOrNull( + result.prv.coefficientOfVariation, + ), + validIntervalCount: Math.max( + 0, + Math.round(result.prv.validIntervalCount), + ), + rejectedIntervalCount: Math.max( + 0, + Math.round(result.prv.rejectedIntervalCount), + ), + rejectedIntervalRatio: result.prv.rejectedIntervalRatio, + reliable: result.prv.reliable, + }, + quality: { + score: result.quality.score, + level: qualityLevel(result.quality.level), + usableDurationSeconds: result.quality.usableDurationSeconds, + droppedFrameRatio: result.quality.droppedFrameRatio, + saturationRatio: result.quality.saturationRatio, + rejectedIntervalRatio: result.quality.rejectedIntervalRatio, + signalToNoiseRatio: finiteOrNull(result.quality.signalToNoiseRatio), + motionArtifactRatio: result.quality.motionArtifactRatio, + methodAgreementScore: result.quality.methodAgreementScore, + timestampJitterRatio: result.quality.timestampJitterRatio, + longestGapSeconds: result.quality.longestGapSeconds, + issues: result.quality.issues.map(qualityIssue), + }, + startedAtMonotonicSeconds: result.startedAtMonotonicSeconds, + endedAtMonotonicSeconds: result.endedAtMonotonicSeconds, + rawSampleCount: Math.max(0, Math.round(result.rawSampleCount)), + resampledSampleCount: Math.max( + 0, + Math.round(result.resampledSampleCount), + ), + }; +} diff --git a/src/features/ppg/measurement/measurementMachine.ts b/src/features/ppg/measurement/measurementMachine.ts new file mode 100644 index 0000000..f2db609 --- /dev/null +++ b/src/features/ppg/measurement/measurementMachine.ts @@ -0,0 +1,377 @@ +import type { + MeasurementEvent, + MeasurementFailure, + MeasurementMachineContext, + MeasurementMachineSnapshot, + MeasurementState, +} from './measurementTypes'; + +const ACTIVE_STATES: ReadonlySet = new Set([ + 'requestingCameraPermission', + 'requestingHealthPermission', + 'preparingCamera', + 'waitingForFinger', + 'stabilizing', + 'measuring', + 'processing', +]); + +const RETRYABLE_STATES: ReadonlySet = new Set([ + 'invalid', + 'cancelled', + 'thermalStop', + 'interrupted', + 'error', +]); + +const initialContext = (): MeasurementMachineContext => ({ + attempt: 0, + shouldRequestHealthKit: false, + cameraPermission: 'unknown', + healthPermission: 'notRequested', + contactState: 'absent', + thermalState: 'unknown', + failure: null, + result: null, +}); + +export const createInitialMeasurementMachine = (): MeasurementMachineSnapshot => + ({ + value: 'idle', + context: initialContext(), + }); + +const withContext = ( + snapshot: MeasurementMachineSnapshot, + context: Partial, + value: MeasurementState = snapshot.value, +): MeasurementMachineSnapshot => ({ + value, + context: {...snapshot.context, ...context}, +}); + +const isUnsafeThermalState = ( + thermalState: MeasurementMachineContext['thermalState'], +): thermalState is 'serious' | 'critical' => + thermalState === 'serious' || thermalState === 'critical'; + +const thermalFailure = ( + thermalState: Extract< + MeasurementMachineContext['thermalState'], + 'serious' | 'critical' + >, +): MeasurementFailure => ({ + code: thermalState === 'critical' ? 'thermalCritical' : 'thermalSerious', + message: + thermalState === 'critical' + ? 'L’iPhone a atteint un état thermique critique.' + : 'L’iPhone est trop chaud pour poursuivre la mesure en sécurité.', + recoverable: true, +}); + +const interruptFailure = (reason: string): MeasurementFailure => ({ + code: 'applicationInterrupted', + message: reason, + recoverable: true, +}); + +const retry = ( + snapshot: MeasurementMachineSnapshot, +): MeasurementMachineSnapshot => { + const canReuseCameraPermission = + snapshot.context.cameraPermission === 'granted'; + + if (!canReuseCameraPermission) { + return withContext( + snapshot, + { + contactState: 'absent', + failure: null, + result: null, + }, + 'idle', + ); + } + + return { + value: 'preparingCamera', + context: { + ...snapshot.context, + attempt: snapshot.context.attempt + 1, + cameraPermission: 'granted', + contactState: 'absent', + failure: null, + result: null, + }, + }; +}; + +/** + * Pure reducer for the product measurement lifecycle. Camera resources and + * timers remain coordinator responsibilities; this reducer only makes legal + * state transitions explicit and deterministic. + */ +export const transitionMeasurement = ( + snapshot: MeasurementMachineSnapshot, + event: MeasurementEvent, +): MeasurementMachineSnapshot => { + if (event.type === 'RESET') { + if (isUnsafeThermalState(snapshot.context.thermalState)) { + return withContext( + snapshot, + {failure: thermalFailure(snapshot.context.thermalState), result: null}, + 'thermalStop', + ); + } + const resetSnapshot = createInitialMeasurementMachine(); + return withContext(resetSnapshot, { + thermalState: snapshot.context.thermalState, + }); + } + + if (event.type === 'THERMAL_STATE_CHANGED') { + if (isUnsafeThermalState(event.thermalState) && ACTIVE_STATES.has(snapshot.value)) { + return withContext( + snapshot, + { + thermalState: event.thermalState, + failure: thermalFailure(event.thermalState), + result: null, + }, + 'thermalStop', + ); + } + return withContext(snapshot, {thermalState: event.thermalState}); + } + + if (event.type === 'CANCEL' && ACTIVE_STATES.has(snapshot.value)) { + return withContext( + snapshot, + {contactState: 'absent', failure: null, result: null}, + 'cancelled', + ); + } + + if (event.type === 'INTERRUPTED' && ACTIVE_STATES.has(snapshot.value)) { + return withContext( + snapshot, + { + contactState: 'absent', + failure: interruptFailure(event.reason), + result: null, + }, + 'interrupted', + ); + } + + if (event.type === 'FAIL' && snapshot.value !== 'completed') { + return withContext( + snapshot, + {failure: event.failure, result: null}, + 'error', + ); + } + + if (event.type === 'RETRY' && RETRYABLE_STATES.has(snapshot.value)) { + if (isUnsafeThermalState(snapshot.context.thermalState)) { + return withContext( + snapshot, + { + failure: thermalFailure(snapshot.context.thermalState), + }, + 'thermalStop', + ); + } + return retry(snapshot); + } + + switch (snapshot.value) { + case 'idle': { + if (event.type !== 'START') { + return snapshot; + } + if (isUnsafeThermalState(snapshot.context.thermalState)) { + return withContext( + snapshot, + { + failure: thermalFailure(snapshot.context.thermalState), + }, + 'thermalStop', + ); + } + return { + value: 'requestingCameraPermission', + context: { + ...initialContext(), + attempt: snapshot.context.attempt + 1, + shouldRequestHealthKit: event.requestHealthKit ?? false, + cameraPermission: 'requesting', + thermalState: snapshot.context.thermalState, + }, + }; + } + + case 'requestingCameraPermission': { + if (event.type === 'CAMERA_PERMISSION_GRANTED') { + const shouldRequestHealthKit = + snapshot.context.shouldRequestHealthKit; + return withContext( + snapshot, + { + cameraPermission: 'granted', + healthPermission: shouldRequestHealthKit + ? 'requesting' + : 'notRequested', + }, + shouldRequestHealthKit + ? 'requestingHealthPermission' + : 'preparingCamera', + ); + } + + if (event.type === 'CAMERA_PERMISSION_DENIED') { + const restricted = event.restricted ?? false; + return withContext( + snapshot, + { + cameraPermission: restricted ? 'restricted' : 'denied', + failure: { + code: restricted + ? 'cameraRestricted' + : 'cameraPermissionDenied', + message: restricted + ? 'L’accès à la caméra est restreint sur cet iPhone.' + : 'L’accès à la caméra a été refusé.', + recoverable: !restricted, + }, + }, + 'error', + ); + } + return snapshot; + } + + case 'requestingHealthPermission': { + if (event.type !== 'HEALTH_PERMISSION_RESOLVED') { + return snapshot; + } + // HealthKit is optional: a failed request or unavailable service never + // blocks camera PPG. Read access itself is intentionally not inferred. + return withContext( + snapshot, + {healthPermission: event.status}, + 'preparingCamera', + ); + } + + case 'preparingCamera': { + if (event.type === 'CAMERA_READY') { + return withContext( + snapshot, + {contactState: 'adjusting', failure: null}, + 'stabilizing', + ); + } + if (event.type === 'CAMERA_PREPARATION_FAILED') { + return withContext( + snapshot, + {failure: event.failure}, + 'error', + ); + } + return snapshot; + } + + case 'waitingForFinger': { + if (event.type !== 'CONTACT_CHANGED') { + return snapshot; + } + return withContext( + snapshot, + {contactState: event.contactState}, + event.contactState === 'valid' ? 'stabilizing' : 'waitingForFinger', + ); + } + + case 'stabilizing': { + if (event.type === 'CONTACT_CHANGED') { + return withContext( + snapshot, + {contactState: event.contactState}, + 'stabilizing', + ); + } + if (event.type === 'STABILIZATION_COMPLETE') { + return withContext(snapshot, {}, 'measuring'); + } + return snapshot; + } + + case 'measuring': { + if (event.type === 'CONTACT_CHANGED') { + return withContext( + snapshot, + {contactState: event.contactState}, + 'measuring', + ); + } + if (event.type === 'ACQUISITION_COMPLETE') { + return withContext(snapshot, {}, 'processing'); + } + return snapshot; + } + + case 'processing': { + if (event.type === 'PROCESSING_SUCCEEDED') { + if ( + !event.result.valid || + event.result.quality.level === 'invalid' + ) { + return withContext( + snapshot, + { + result: event.result, + failure: { + code: 'poorSignal', + message: + event.result.invalidReason ?? + 'La qualité du signal est insuffisante.', + recoverable: true, + }, + }, + 'invalid', + ); + } + return withContext( + snapshot, + {result: event.result, failure: null}, + 'completed', + ); + } + + if (event.type === 'PROCESSING_INVALID') { + return withContext( + snapshot, + { + result: event.result ?? null, + failure: { + code: 'poorSignal', + message: event.reason, + recoverable: true, + }, + }, + 'invalid', + ); + } + return snapshot; + } + + case 'completed': + case 'invalid': + case 'cancelled': + case 'thermalStop': + case 'interrupted': + case 'error': + return snapshot; + } +}; diff --git a/src/features/ppg/measurement/measurementTypes.ts b/src/features/ppg/measurement/measurementTypes.ts new file mode 100644 index 0000000..55fcaf8 --- /dev/null +++ b/src/features/ppg/measurement/measurementTypes.ts @@ -0,0 +1,186 @@ +export type MeasurementState = + | 'idle' + | 'requestingCameraPermission' + | 'requestingHealthPermission' + | 'preparingCamera' + | 'waitingForFinger' + | 'stabilizing' + | 'measuring' + | 'processing' + | 'completed' + | 'invalid' + | 'cancelled' + | 'thermalStop' + | 'interrupted' + | 'error'; + +export type FingerContactState = + | 'absent' + | 'adjusting' + | 'valid' + | 'tooMuchPressure' + | 'overexposed' + | 'unstable'; + +export type ThermalState = + | 'nominal' + | 'fair' + | 'serious' + | 'critical' + | 'unknown'; + +export type CameraPermissionStatus = + | 'unknown' + | 'requesting' + | 'granted' + | 'denied' + | 'restricted'; + +export type HealthPermissionStatus = + | 'notRequested' + | 'requesting' + | 'requestCompleted' + | 'requestFailed' + | 'unavailable'; + +export type SignalQualityLevel = 'good' | 'medium' | 'poor' | 'invalid'; + +export type SignalQualityIssue = + | 'contactInsufficient' + | 'contactInterrupted' + | 'durationInsufficient' + | 'droppedFrames' + | 'timestampJitter' + | 'longGap' + | 'saturation' + | 'motionArtifacts' + | 'lowPulsatileAmplitude' + | 'lowSignalToNoiseRatio' + | 'tooFewPeaks' + | 'tooManyRejectedPeaks' + | 'tooManyRejectedIntervals' + | 'methodDisagreement' + | 'channelInstability' + | 'nonFiniteProcessingResult'; + +export interface BPMEstimate { + bpm: number | null; + temporalBpm: number | null; + frequencyBpm: number | null; + methodAgreementBpm: number | null; + validPeakCount: number; + rejectedPeakCount: number; + confidence: number; + selectedChannel: string; +} + +export interface PRVMetrics { + estimatedRmssdMs: number | null; + estimatedSdnnMs: number | null; + meanIpiMs: number | null; + medianIpiMs: number | null; + pnn50Percent: number | null; + rangeIpiMs: number | null; + modeIpiMs: number | null; + amo50Percent: number | null; + coefficientOfVariation: number | null; + validIntervalCount: number; + rejectedIntervalCount: number; + rejectedIntervalRatio: number; + reliable: boolean; +} + +export interface SignalQualityResult { + score: number; + level: SignalQualityLevel; + usableDurationSeconds: number; + droppedFrameRatio: number; + saturationRatio: number; + rejectedIntervalRatio: number; + signalToNoiseRatio: number | null; + motionArtifactRatio: number; + methodAgreementScore: number; + timestampJitterRatio: number; + longestGapSeconds: number; + issues: readonly SignalQualityIssue[]; +} + +export interface PPGProcessingResult { + valid: boolean; + invalidReason: string | null; + bpm: BPMEstimate; + prv: PRVMetrics; + quality: SignalQualityResult; + startedAtMonotonicSeconds: number; + endedAtMonotonicSeconds: number; + rawSampleCount: number; + resampledSampleCount: number; +} + +export type MeasurementFailureCode = + | 'cameraPermissionDenied' + | 'cameraRestricted' + | 'cameraUnavailable' + | 'torchUnavailable' + | 'cameraRuntime' + | 'cameraInterrupted' + | 'pluginFailure' + | 'processingFailure' + | 'poorSignal' + | 'insufficientDuration' + | 'thermalSerious' + | 'thermalCritical' + | 'applicationInterrupted' + | 'memoryPressure' + | 'unknown'; + +export interface MeasurementFailure { + code: MeasurementFailureCode; + message: string; + recoverable: boolean; +} + +export interface MeasurementMachineContext { + attempt: number; + shouldRequestHealthKit: boolean; + cameraPermission: CameraPermissionStatus; + healthPermission: HealthPermissionStatus; + contactState: FingerContactState; + thermalState: ThermalState; + failure: MeasurementFailure | null; + result: PPGProcessingResult | null; +} + +export interface MeasurementMachineSnapshot { + value: MeasurementState; + context: MeasurementMachineContext; +} + +export type MeasurementEvent = + | {type: 'START'; requestHealthKit?: boolean} + | {type: 'CAMERA_PERMISSION_GRANTED'} + | {type: 'CAMERA_PERMISSION_DENIED'; restricted?: boolean} + | { + type: 'HEALTH_PERMISSION_RESOLVED'; + status: Extract< + HealthPermissionStatus, + 'requestCompleted' | 'requestFailed' | 'unavailable' + >; + } + | {type: 'CAMERA_READY'} + | {type: 'CAMERA_PREPARATION_FAILED'; failure: MeasurementFailure} + | {type: 'CONTACT_CHANGED'; contactState: FingerContactState} + | {type: 'STABILIZATION_COMPLETE'} + | {type: 'ACQUISITION_COMPLETE'} + | {type: 'PROCESSING_SUCCEEDED'; result: PPGProcessingResult} + | { + type: 'PROCESSING_INVALID'; + reason: string; + result?: PPGProcessingResult; + } + | {type: 'THERMAL_STATE_CHANGED'; thermalState: ThermalState} + | {type: 'INTERRUPTED'; reason: string} + | {type: 'FAIL'; failure: MeasurementFailure} + | {type: 'CANCEL'} + | {type: 'RETRY'} + | {type: 'RESET'}; diff --git a/src/features/ppg/measurement/usePPGMeasurement.ts b/src/features/ppg/measurement/usePPGMeasurement.ts new file mode 100644 index 0000000..1ae800c --- /dev/null +++ b/src/features/ppg/measurement/usePPGMeasurement.ts @@ -0,0 +1,490 @@ +import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; +import {AppState, type AppStateStatus} from 'react-native'; +import { + haptikaHealthKit, + haptikaPPG, + type NativePPGConfiguration, + type PPGFrameUpdate, +} from 'react-native-haptika-ppg'; + +import type {PPGCameraError} from '../camera/cameraTypes'; +import { + createMeasurementConfiguration, + type MeasurementConfiguration, +} from './measurementConfiguration'; +import {mapNativeProcessingResult} from './measurementCoordinator'; +import { + createInitialMeasurementMachine, + transitionMeasurement, +} from './measurementMachine'; +import type { + MeasurementEvent, + MeasurementMachineSnapshot, + ThermalState, +} from './measurementTypes'; + +interface StartMeasurementOptions { + includeHealthKitContext: boolean; + sensitiveContact: boolean; + requestCameraPermission: () => Promise; +} + +export interface PPGMeasurementController { + snapshot: MeasurementMachineSnapshot; + latestFrame: PPGFrameUpdate | null; + phaseProgress: number; + isCameraActive: boolean; + torchEnabled: boolean; + start: (options: StartMeasurementOptions) => Promise; + retry: () => void; + cancel: () => void; + reset: () => void; + onCameraStarted: () => void; + onCameraError: (error: PPGCameraError) => void; + onCameraInterrupted: (reason: string) => void; + onFrameUpdate: (update: PPGFrameUpdate) => void; +} + +const CAMERA_STATES = new Set([ + 'preparingCamera', + 'waitingForFinger', + 'stabilizing', + 'measuring', +]); + +const TERMINAL_STATES = new Set([ + 'idle', + 'invalid', + 'cancelled', + 'thermalStop', + 'interrupted', + 'error', +]); + +function nativeConfiguration( + configuration: MeasurementConfiguration, +): NativePPGConfiguration { + return { + sessionPurpose: 'spotMeasurement', + targetSampleRate: configuration.targetSampleRate, + minimumFrequencyHz: configuration.minimumFrequencyHz, + maximumFrequencyHz: configuration.maximumFrequencyHz, + stabilizationSeconds: configuration.stabilizationSeconds, + measurementSeconds: configuration.measurementSeconds, + emitEveryFrames: configuration.emitEveryFrames, + minimumContactConfidence: configuration.minimumContactConfidence, + }; +} + +export function usePPGMeasurement(): PPGMeasurementController { + const configuration = useMemo(() => createMeasurementConfiguration(), []); + const [snapshot, dispatch] = useReducer( + transitionMeasurement, + undefined, + createInitialMeasurementMachine, + ); + const [latestFrame, setLatestFrame] = useState(null); + const [phaseProgress, setPhaseProgress] = useState(0); + const snapshotRef = useRef(snapshot); + const latestFrameRef = useRef(null); + const phaseStartedAtRef = useRef(null); + const lastStabilizationFrameAtRef = useRef(null); + const lastStabilizationFrameWasValidRef = useRef(false); + const stableDurationRef = useRef(0); + const measurementStartedAtUsableDurationRef = useRef(0); + const measurementStartedAtBeatRef = useRef(0); + const nativeMeasurementStartedRef = useRef(false); + const processingStartedRef = useRef(false); + const minimumContactConfidenceRef = useRef( + configuration.minimumContactConfidence, + ); + + useEffect(() => { + snapshotRef.current = snapshot; + }, [snapshot]); + + const send = useCallback((event: MeasurementEvent): void => { + dispatch(event); + }, []); + + const stopNativeMeasurement = useCallback((): void => { + if (!nativeMeasurementStartedRef.current) { + return; + } + try { + haptikaPPG.cancelMeasurement(); + } catch { + // Best effort: the native session may already have stopped itself. + } finally { + nativeMeasurementStartedRef.current = false; + } + }, []); + + const refreshThermalState = useCallback((): ThermalState | null => { + try { + const thermalState = haptikaPPG.getThermalState(); + send({type: 'THERMAL_STATE_CHANGED', thermalState}); + return thermalState; + } catch (error) { + send({ + type: 'FAIL', + failure: { + code: 'pluginFailure', + message: + error instanceof Error + ? error.message + : 'Impossible de vérifier la température de l’iPhone.', + recoverable: true, + }, + }); + return null; + } + }, [send]); + + useEffect(() => { + if (snapshot.value === 'preparingCamera' && !nativeMeasurementStartedRef.current) { + try { + haptikaPPG.startMeasurement({ + ...nativeConfiguration(configuration), + minimumContactConfidence: minimumContactConfidenceRef.current, + }); + nativeMeasurementStartedRef.current = true; + processingStartedRef.current = false; + setLatestFrame(null); + latestFrameRef.current = null; + setPhaseProgress(0); + } catch (error) { + send({ + type: 'FAIL', + failure: { + code: 'pluginFailure', + message: + error instanceof Error + ? error.message + : 'Le module PPG natif n’a pas pu démarrer.', + recoverable: true, + }, + }); + } + } + + if (snapshot.value === 'stabilizing' || snapshot.value === 'measuring') { + phaseStartedAtRef.current = latestFrameRef.current?.timestampSeconds ?? null; + lastStabilizationFrameAtRef.current = null; + lastStabilizationFrameWasValidRef.current = false; + stableDurationRef.current = 0; + if (snapshot.value === 'measuring') { + measurementStartedAtBeatRef.current = + latestFrameRef.current?.beatSequence ?? 0; + measurementStartedAtUsableDurationRef.current = + latestFrameRef.current?.usableDurationSeconds ?? 0; + } + setPhaseProgress(0); + } + + if (snapshot.value === 'processing' && !processingStartedRef.current) { + processingStartedRef.current = true; + try { + const result = mapNativeProcessingResult(haptikaPPG.finishMeasurement()); + nativeMeasurementStartedRef.current = false; + send({type: 'PROCESSING_SUCCEEDED', result}); + } catch (error) { + nativeMeasurementStartedRef.current = false; + send({ + type: 'FAIL', + failure: { + code: 'processingFailure', + message: + error instanceof Error + ? error.message + : 'Le traitement du signal a échoué.', + recoverable: true, + }, + }); + } + } + + if (TERMINAL_STATES.has(snapshot.value)) { + stopNativeMeasurement(); + } + }, [configuration, send, snapshot.value, stopNativeMeasurement]); + + useEffect(() => { + const onAppStateChange = (nextState: AppStateStatus): void => { + if ( + nextState !== 'active' && + CAMERA_STATES.has(snapshotRef.current.value) + ) { + stopNativeMeasurement(); + send({ + type: 'INTERRUPTED', + reason: 'La mesure a été interrompue lorsque Haptika a quitté l’écran actif.', + }); + } + }; + const subscription = AppState.addEventListener('change', onAppStateChange); + const memorySubscription = AppState.addEventListener( + 'memoryWarning', + (): void => { + if (CAMERA_STATES.has(snapshotRef.current.value)) { + stopNativeMeasurement(); + send({ + type: 'FAIL', + failure: { + code: 'memoryPressure', + message: + 'La mesure a été arrêtée pour libérer les ressources de l’iPhone.', + recoverable: true, + }, + }); + } + }, + ); + return () => { + subscription.remove(); + memorySubscription.remove(); + }; + }, [send, stopNativeMeasurement]); + + useEffect(() => { + return stopNativeMeasurement; + }, [stopNativeMeasurement]); + + const start = useCallback( + async ({ + includeHealthKitContext, + sensitiveContact, + requestCameraPermission, + }: StartMeasurementOptions): Promise => { + minimumContactConfidenceRef.current = sensitiveContact + ? 0.45 + : configuration.minimumContactConfidence; + send({type: 'START', requestHealthKit: includeHealthKitContext}); + try { + const cameraGranted = await requestCameraPermission(); + if (!cameraGranted) { + send({type: 'CAMERA_PERMISSION_DENIED'}); + return; + } + send({type: 'CAMERA_PERMISSION_GRANTED'}); + + if (includeHealthKitContext) { + if (!haptikaHealthKit.isAvailable()) { + send({ + type: 'HEALTH_PERMISSION_RESOLVED', + status: 'unavailable', + }); + return; + } + const requestCompleted = + await haptikaHealthKit.requestReadAuthorization(); + send({ + type: 'HEALTH_PERMISSION_RESOLVED', + status: requestCompleted + ? 'requestCompleted' + : 'requestFailed', + }); + } + } catch (error) { + send({ + type: 'FAIL', + failure: { + code: 'unknown', + message: + error instanceof Error + ? error.message + : 'Impossible de préparer la mesure.', + recoverable: true, + }, + }); + } + }, + [configuration.minimumContactConfidence, send], + ); + + const onFrameUpdate = useCallback( + (update: PPGFrameUpdate): void => { + latestFrameRef.current = update; + setLatestFrame(update); + send({ + type: 'THERMAL_STATE_CHANGED', + thermalState: update.thermalState, + }); + + const current = snapshotRef.current; + if ( + update.thermalState === 'serious' || + update.thermalState === 'critical' + ) { + return; + } + if (current.context.contactState !== update.contactState) { + send({type: 'CONTACT_CHANGED', contactState: update.contactState}); + } + + if (current.value === 'stabilizing') { + const previous = lastStabilizationFrameAtRef.current; + const previousWasValid = lastStabilizationFrameWasValidRef.current; + lastStabilizationFrameAtRef.current = update.timestampSeconds; + lastStabilizationFrameWasValidRef.current = + update.contactState === 'valid'; + if ( + update.contactState === 'valid' && + previousWasValid && + previous !== null + ) { + const delta = update.timestampSeconds - previous; + if (delta > 0 && delta <= 0.5) { + stableDurationRef.current += delta; + } + } + const stableDuration = stableDurationRef.current; + setPhaseProgress( + Math.min(stableDuration / configuration.stabilizationSeconds, 1), + ); + if (stableDuration >= configuration.stabilizationSeconds) { + send({type: 'STABILIZATION_COMPLETE'}); + } + } + + if (current.value === 'measuring') { + // Keep the acquisition clock running through brief contact instability. + // Native processing still stores only valid frames and reports the + // resulting quality at the end. + const startTime = phaseStartedAtRef.current ?? update.timestampSeconds; + phaseStartedAtRef.current = startTime; + const elapsed = Math.max(0, update.timestampSeconds - startTime); + const collectedBeats = Math.max( + 0, + update.beatSequence - measurementStartedAtBeatRef.current, + ); + const usableDuration = Math.max( + 0, + update.usableDurationSeconds - + measurementStartedAtUsableDurationRef.current, + ); + const timeProgress = + usableDuration / configuration.minimumMeasurementSeconds; + const beatProgress = + collectedBeats / configuration.targetValidBeatCount; + const readyFromSignal = + usableDuration >= configuration.minimumMeasurementSeconds && + collectedBeats >= configuration.targetValidBeatCount; + const reachedSafetyLimit = elapsed >= configuration.measurementSeconds; + setPhaseProgress( + readyFromSignal || reachedSafetyLimit + ? 1 + : Math.min(Math.min(timeProgress, beatProgress), 0.99), + ); + if (readyFromSignal) { + send({type: 'ACQUISITION_COMPLETE'}); + } else if (reachedSafetyLimit) { + if (update.collectedSampleCount >= 300 && usableDuration >= 20) { + // The native pipeline may still publish a conservative BPM while + // explicitly marking beat-to-beat variability as unreliable. + send({type: 'ACQUISITION_COMPLETE'}); + } else { + send({ + type: 'FAIL', + failure: { + code: 'poorSignal', + message: + update.collectedSampleCount < 300 + ? 'La caméra ne transmet pas assez de trames pour mesurer le pouls.' + : 'Moins de 20 secondes de signal optique ont été utilisables.', + recoverable: true, + }, + }); + } + } + } + }, + [ + configuration.measurementSeconds, + configuration.minimumMeasurementSeconds, + configuration.stabilizationSeconds, + configuration.targetValidBeatCount, + send, + ], + ); + + const onCameraStarted = useCallback((): void => { + send({type: 'CAMERA_READY'}); + }, [send]); + + const onCameraError = useCallback( + (error: PPGCameraError): void => { + stopNativeMeasurement(); + send({ + type: 'FAIL', + failure: { + code: error.code, + message: error.message, + recoverable: true, + }, + }); + }, + [send, stopNativeMeasurement], + ); + + const onCameraInterrupted = useCallback( + (reason: string): void => { + stopNativeMeasurement(); + send({type: 'INTERRUPTED', reason}); + }, + [send, stopNativeMeasurement], + ); + + const retry = useCallback((): void => { + stopNativeMeasurement(); + processingStartedRef.current = false; + phaseStartedAtRef.current = null; + + if (snapshotRef.current.value === 'thermalStop') { + const thermalState = refreshThermalState(); + if (thermalState !== 'nominal' && thermalState !== 'fair') { + return; + } + } + send({type: 'RETRY'}); + }, [refreshThermalState, send, stopNativeMeasurement]); + + const cancel = useCallback((): void => { + stopNativeMeasurement(); + send({type: 'CANCEL'}); + }, [send, stopNativeMeasurement]); + const reset = useCallback((): void => { + stopNativeMeasurement(); + processingStartedRef.current = false; + phaseStartedAtRef.current = null; + setLatestFrame(null); + setPhaseProgress(0); + + if (snapshotRef.current.value === 'thermalStop') { + const thermalState = refreshThermalState(); + if (thermalState !== 'nominal' && thermalState !== 'fair') { + return; + } + } + send({type: 'RESET'}); + }, [refreshThermalState, send, stopNativeMeasurement]); + + return { + snapshot, + latestFrame, + phaseProgress, + isCameraActive: CAMERA_STATES.has(snapshot.value), + torchEnabled: + snapshot.value === 'waitingForFinger' || + snapshot.value === 'stabilizing' || + snapshot.value === 'measuring', + start, + retry, + cancel, + reset, + onCameraStarted, + onCameraError, + onCameraInterrupted, + onFrameUpdate, + }; +} diff --git a/src/features/ppg/screens/PPGFailureScreen.tsx b/src/features/ppg/screens/PPGFailureScreen.tsx new file mode 100644 index 0000000..41f8cdc --- /dev/null +++ b/src/features/ppg/screens/PPGFailureScreen.tsx @@ -0,0 +1,242 @@ +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 = { + 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 ( + + + {thermal ? '°' : '!'} + + + {thermal ? 'L’iPhone doit refroidir' : 'Mesure non exploitable'} + + {message} + {!thermal && result !== null ? ( + setShowDiagnostic(value => !value)} + style={styles.diagnosticToggle}> + Détails techniques + {showDiagnostic ? '−' : '+'} + + ) : null} + {!thermal && result !== null && showDiagnostic ? ( + + Diagnostic de cette mesure + + Qualité : {percent(result.quality.score)} ({qualityLabel(result)}) + + + Signal utile : {result.quality.usableDurationSeconds.toFixed(1)} s · + mouvement : {percent(result.quality.motionArtifactRatio)} + + + Saturation : {percent(result.quality.saturationRatio)} · pertes :{' '} + {percent(result.quality.droppedFrameRatio)} + + + SNR :{' '} + {result.quality.signalToNoiseRatio === null + ? 'indisponible' + : result.quality.signalToNoiseRatio.toFixed(1)}{' '} + · battements : {result.bpm.validPeakCount} + + + Trames : {result.rawSampleCount} · rééchantillonnées :{' '} + {result.resampledSampleCount} + + {result.quality.issues.length > 0 ? ( + + Cause(s) :{' '} + {result.quality.issues.map(issue => ISSUE_LABELS[issue]).join(', ')} + + ) : null} + + ) : null} + + Pour le prochain essai + + 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. + + + + {showOpenSettings ? ( + Linking.openSettings().catch(() => undefined)} + /> + ) : null} + + + ); +} + +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, + }, +}); diff --git a/src/features/ppg/screens/PPGHistoryScreen.tsx b/src/features/ppg/screens/PPGHistoryScreen.tsx new file mode 100644 index 0000000..e26c3c9 --- /dev/null +++ b/src/features/ppg/screens/PPGHistoryScreen.tsx @@ -0,0 +1,394 @@ +import React, {useMemo, useState} from 'react'; +import {Alert, Pressable, ScrollView, Share, StyleSheet, Switch, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {colors, radii, spacing} from '../../../theme'; +import {compareRegulationMeasurements} from '../../cardiacMirror/regulationComparison'; +import {getRegulationProtocol} from '../../cardiacMirror/regulationProtocols'; +import type {RegulationSession} from '../../cardiacMirror/regulationSessionTypes'; +import {PrimaryButton} from '../components/PrimaryButton'; +import {MetricTrendChart} from '../components/MetricTrendChart'; +import type {MetricTrendPoint} from '../insights/metricInsights'; +import {buildDailyTrends, buildFeelingCorrelation} from '../history/historyAnalytics'; +import type {BaselineCalculationResult} from '../stress/baselineTypes'; +import { + exportMeasurementsAsCSV, + exportMeasurementsAsJSON, +} from '../storage/measurementRepository'; +import type { + DataRetentionPolicy, + StoredMeasurement, +} from '../storage/storageTypes'; +import {buildWatchDailyTrend, compareWatchRegulationSession} from '../../watch/watchAnalytics'; +import type {WatchBaselineV1} from '../../watch/watchBaseline'; +import type { + WatchConnectionState, + WatchMeasurementSummaryV1, + WatchRegulationSessionV1, +} from '../../watch/watchTypes'; + +type SourceFilter = 'all' | 'camera' | 'watch' | 'front' | 'back'; + +interface PPGHistoryScreenProps { + measurements: readonly StoredMeasurement[]; + watchMeasurements: readonly WatchMeasurementSummaryV1[]; + baselineProgress: BaselineCalculationResult | null; + watchBaseline: WatchBaselineV1 | null; + regulationSessions: readonly RegulationSession[]; + watchRegulationSessions: readonly WatchRegulationSessionV1[]; + watchConnectionState: WatchConnectionState; + saveMindAndBodyWorkouts: boolean; + retentionPolicy: DataRetentionPolicy; + onDeleteMeasurement: (measurementId: string) => Promise; + onDeleteWatchMeasurement: (measurementId: string) => Promise; + onClearAll: () => Promise; + onSaveMindAndBodyWorkoutsChange: (value: boolean) => void; + onRetentionPolicyChange: (policy: DataRetentionPolicy) => Promise; + onBack: () => void; +} + +function WatchMeasurementDetail({ + measurement, + watchBaseline, + onClose, + onDelete, +}: { + measurement: WatchMeasurementSummaryV1; + watchBaseline: WatchBaselineV1 | null; + onClose: () => void; + onDelete: () => void; +}): React.JSX.Element { + const delta = measurement.averageBpm === null || watchBaseline === null + ? null + : measurement.averageBpm - watchBaseline.medianBpm; + return + + MESURE APPLE WATCH{new Date(measurement.endedAt).toLocaleString('fr-FR')} + Fermer + + + Pouls moyen{formatMetric(measurement.averageBpm, 'BPM')} + Minimum / maximum{measurement.minimumBpm === null || measurement.maximumBpm === null ? '—' : `${Math.round(measurement.minimumBpm)}–${Math.round(measurement.maximumBpm)}`} + Qualité{Math.round(measurement.quality * 100)} % + Couverture{Math.round(measurement.coverage * 100)} % + Écart baseline Watch{delta === null ? '—' : `${delta >= 0 ? '+' : ''}${Math.round(delta)} BPM`} + Ressenti{measurement.feeling === null ? '—' : `${measurement.feeling.score}/5`} + + {measurement.device.name} · {measurement.device.model ?? 'modèle non renseigné'} · capteur optique Apple Watch + {measurement.samples.length} échantillons de fréquence cardiaque. Aucune PRV/RMSSD n’est calculée à partir de cette source. + + ; +} + +const formatMetric = (value: number | null, unit: string): string => + value === null ? '—' : `${Math.round(value)} ${unit}`; + +function TrendChart({ + points, + color, + unit, +}: { + points: readonly {day: string; value: number | null}[]; + color: string; + unit: string; +}): React.JSX.Element { + const trend = points.flatMap((point, index) => + point.value === null || !Number.isFinite(point.value) + ? [] + : [{id: `${point.day}-${index}`, measuredAt: point.day, value: point.value}], + ); + return ( + + ); +} + +function MeasurementDetail({ + measurement, + baselineProgress, + onClose, + onDelete, +}: { + measurement: StoredMeasurement; + baselineProgress: BaselineCalculationResult | null; + onClose: () => void; + onDelete: () => void; +}): React.JSX.Element { + const result = measurement.cameraResult; + const prv = result.prv; + const baselineBpm = baselineProgress?.baseline?.medianBpm ?? null; + const delta = result.bpm.bpm === null || baselineBpm === null + ? null + : result.bpm.bpm - baselineBpm; + return ( + + + + DÉTAIL DE LA MESURE + + {new Date(measurement.measuredAt).toLocaleString('fr-FR')} + + + + Fermer + + + + Pouls moyen{formatMetric(result.bpm.bpm, 'BPM')} + RMSSD pulsatile{formatMetric(prv.estimatedRmssdMs, 'ms')} + SDNN pulsatile{formatMetric(prv.estimatedSdnnMs, 'ms')} + Qualité{Math.round(result.quality.score * 100)} % + Écart baseline{delta === null ? '—' : `${delta >= 0 ? '+' : ''}${Math.round(delta)} BPM`} + Ressenti{measurement.selfReport === null ? '—' : `${measurement.selfReport.feeling}/5`} + + + Caméra {measurement.acquisition.cameraPosition === 'unknown' ? 'non renseignée' : measurement.acquisition.cameraPosition === 'front' ? 'avant' : 'arrière'} + {' · '}flash {measurement.acquisition.torchUsed === null ? 'non renseigné' : measurement.acquisition.torchUsed ? 'utilisé' : 'non utilisé'} + {' · '}signal utile {result.quality.usableDurationSeconds.toFixed(1)} s + + + IPI valides {prv.validIntervalCount} · rejetés {prv.rejectedIntervalCount} · indice {measurement.potentialStress.index ?? 'indisponible'} + + + Baseline : {measurement.baselineEligible ? 'mesure comparable' : 'mesure exclue'} · algorithme {measurement.algorithmVersion ?? 'non renseigné'} + + + + ); +} + +export function PPGHistoryScreen({ + measurements, + watchMeasurements, + baselineProgress, + watchBaseline, + regulationSessions, + watchRegulationSessions, + watchConnectionState, + saveMindAndBodyWorkouts, + retentionPolicy, + onDeleteMeasurement, + onDeleteWatchMeasurement, + onClearAll, + onSaveMindAndBodyWorkoutsChange, + onRetentionPolicyChange, + onBack, +}: PPGHistoryScreenProps): React.JSX.Element { + const insets = useSafeAreaInsets(); + const [sourceFilter, setSourceFilter] = useState('all'); + const [selectedId, setSelectedId] = useState(null); + const [selectedWatchId, setSelectedWatchId] = useState(null); + const [busy, setBusy] = useState(false); + const filtered = useMemo( + () => measurements.filter(measurement => + sourceFilter === 'all' || sourceFilter === 'camera' || measurement.acquisition.cameraPosition === sourceFilter, + ), + [sourceFilter, measurements], + ); + const filteredWatch = useMemo( + () => sourceFilter === 'all' || sourceFilter === 'watch' ? watchMeasurements : [], + [sourceFilter, watchMeasurements], + ); + const trends = useMemo(() => buildDailyTrends(filtered, 30), [filtered]); + const correlations = useMemo(() => buildFeelingCorrelation(filtered), [filtered]); + const selected = measurements.find(item => item.id === selectedId) ?? null; + const selectedWatch = watchMeasurements.find(item => item.id === selectedWatchId) ?? null; + const watchTrends = useMemo(() => buildWatchDailyTrend(filteredWatch, 7), [filteredWatch]); + const measurementById = new Map(measurements.map(item => [item.id, item])); + const completedRegulations = regulationSessions.flatMap(session => { + if (session.status !== 'completed' || session.afterMeasurementId === null) return []; + const before = measurementById.get(session.beforeMeasurementId); + const after = measurementById.get(session.afterMeasurementId); + return before === undefined || after === undefined + ? [] + : [{session, comparison: compareRegulationMeasurements(before, after)}]; + }); + const completedWatchRegulations = watchRegulationSessions.flatMap(session => { + const comparison = compareWatchRegulationSession(session, watchMeasurements); + return comparison === null ? [] : [{session, comparison}]; + }); + + const confirmDelete = (measurement: StoredMeasurement): void => { + Alert.alert('Supprimer cette mesure ?', 'Cette action est définitive et supprimera aussi les comparaisons liées.', [ + {text: 'Annuler', style: 'cancel'}, + {text: 'Supprimer', style: 'destructive', onPress: () => { + setBusy(true); + onDeleteMeasurement(measurement.id).then(() => setSelectedId(null)).finally(() => setBusy(false)); + }}, + ]); + }; + const confirmWatchDelete = (measurement: WatchMeasurementSummaryV1): void => { + Alert.alert('Supprimer cette mesure Watch ?', 'La suppression sera synchronisée avec votre Apple Watch appairée.', [ + {text: 'Annuler', style: 'cancel'}, + {text: 'Supprimer', style: 'destructive', onPress: () => { + setBusy(true); + onDeleteWatchMeasurement(measurement.id).then(() => setSelectedWatchId(null)).finally(() => setBusy(false)); + }}, + ]); + }; + + const confirmClear = (): void => { + Alert.alert('Effacer les données de mesure ?', 'Toutes les mesures et séances de régulation locales seront définitivement supprimées. Vos préférences seront conservées.', [ + {text: 'Annuler', style: 'cancel'}, + {text: 'Tout effacer', style: 'destructive', onPress: () => { + setBusy(true); + onClearAll().then(() => setSelectedId(null)).finally(() => setBusy(false)); + }}, + ]); + }; + + const shareExport = async (format: 'json' | 'csv'): Promise => { + const message = format === 'json' + ? exportMeasurementsAsJSON(measurements) + : exportMeasurementsAsCSV(measurements); + await Share.share({title: `Mesures Haptika (.${format})`, message}); + }; + + return ( + + DONNÉES LOCALES + Votre évolution + Pouls, variabilité pulsatile et qualité, sans mélanger les sources de mesure. + + {selected !== null ? ( + setSelectedId(null)} onDelete={() => confirmDelete(selected)} /> + ) : null} + {selectedWatch !== null ? setSelectedWatchId(null)} onDelete={() => confirmWatchDelete(selectedWatch)} /> : null} + + + {(['all', 'camera', 'watch', 'front', 'back'] as const).map(filter => ( + setSourceFilter(filter)} style={[styles.filter, sourceFilter === filter && styles.filterActive]} accessibilityRole="radio" accessibilityState={{checked: sourceFilter === filter}}> + {filter === 'all' ? 'Toutes' : filter === 'camera' ? 'Caméra' : filter === 'watch' ? 'Watch' : filter === 'front' ? 'Avant' : 'Arrière'} + + ))} + + + + 30 DERNIERS JOURS + {trends.reduce((sum, point) => sum + point.count, 0) + watchTrends.reduce((sum, point) => sum + point.count, 0)} mesures valides + {filtered.length > 0 ? <>Pouls moyen · caméra iPhone + ({day: point.day, value: point.bpm}))} + color={colors.accent} + unit="BPM" + /> + RMSSD pulsatile · caméra uniquement + ({day: point.day, value: point.rmssdMs}))} + color={colors.good} + unit="ms" + /> + Qualité du signal + ({day: point.day, value: point.quality * 100}))} + color={colors.medium} + unit="%" + /> : null} + {filteredWatch.length > 0 ? <>Pouls moyen · Apple Watch (7 jours) ({day: point.day, value: point.averageBpm}))} color={colors.good} unit="BPM" />Qualité Watch ({day: point.day, value: point.averageQuality * 100}))} color={colors.accent} unit="%" /> : null} + {trends.length === 0 ? 'Pas encore assez de données.' : `${trends[0]?.day ?? ''} → ${trends[trends.length - 1]?.day ?? ''}`} + + + + APPLE WATCH + {watchConnectionState.watchAppInstalled ? watchConnectionState.reachable ? 'Connectée maintenant' : 'Installée · synchronisation différée' : watchConnectionState.paired ? 'App Watch non installée' : 'Aucune Watch appairée'} + Dernier événement : {watchConnectionState.latestEventAt === null ? 'aucun' : new Date(watchConnectionState.latestEventAt).toLocaleString('fr-FR')} + Enregistrer les séances guidées dans Santé + Les mesures techniques de 60 secondes sont toujours écartées de l’historique des workouts Santé. + + + + BASELINE PERSONNELLE + {baselineProgress?.baseline?.validMeasurementCount ?? 0} mesures comparables + Pouls médian {baselineProgress?.baseline === null || baselineProgress?.baseline === undefined ? '—' : `${Math.round(baselineProgress.baseline.medianBpm)} BPM`} · qualité médiane {baselineProgress?.baseline === null || baselineProgress?.baseline === undefined ? '—' : `${Math.round(baselineProgress.baseline.medianSignalQuality * 100)} %`} + + + BASELINE APPLE WATCH · SÉPARÉE + {watchBaseline?.validMeasurementCount ?? 0} mesures comparables + {watchBaseline === null ? 'Baseline en construction.' : `${watchBaseline.status === 'personalized' ? 'Personnalisée' : watchBaseline.status === 'provisional' ? 'Provisoire' : 'En construction'} · pouls médian ${Math.round(watchBaseline.medianBpm)} BPM`} + + + {correlations.length > 0 ? ( + + RESSENTI ET MESURE + Corrélation exploratoire + {correlations.map(point => Ressenti {point.feeling}/5 · {formatMetric(point.averageBpm, 'BPM')} · RMSSD {formatMetric(point.averageRmssdMs, 'ms')} · n={point.count})} + Association descriptive uniquement, pas une relation médicale ni causale. + + ) : null} + + {completedRegulations.length > 0 ? Séances de régulation : null} + {completedRegulations.map(({session, comparison}) => ( + + {getRegulationProtocol(session.protocolKind).title} + {comparison.bpm === null ? 'Pouls non comparable' : `${Math.round(comparison.bpm.before)} → ${Math.round(comparison.bpm.after)} BPM`} + {comparison.rmssdMs === null ? 'PRV non comparable' : `RMSSD ${Math.round(comparison.rmssdMs.before)} → ${Math.round(comparison.rmssdMs.after)} ms`} + + ))} + {completedWatchRegulations.map(({session, comparison}) => {session.protocolId} · APPLE WATCH{Math.round(comparison.bpm.before)} → {Math.round(comparison.bpm.after)} BPMQualité {Math.round(comparison.quality.before * 100)} → {Math.round(comparison.quality.after * 100)} % · PRV indisponible sur cette source)} + + Toutes les mesures + {filtered.length === 0 && filteredWatch.length === 0 ? Aucune mesure pour ce filtre. : filtered.map(measurement => ( + setSelectedId(measurement.id)} style={styles.measurementRow} accessibilityRole="button"> + {new Date(measurement.measuredAt).toLocaleString('fr-FR', {day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit'})}{measurement.acquisition.cameraPosition === 'front' ? 'Avant' : measurement.acquisition.cameraPosition === 'back' ? 'Arrière' : 'Caméra ?'} + {formatMetric(measurement.cameraResult.bpm.bpm, 'BPM')}RMSSD {formatMetric(measurement.cameraResult.prv.estimatedRmssdMs, 'ms')} + Qualité {Math.round(measurement.cameraResult.quality.score * 100)} % · {measurement.potentialStress.index === null ? 'baseline en construction' : `indice ${measurement.potentialStress.index}/100`} · toucher pour le détail + + ))} + {filteredWatch.map(measurement => {setSelectedId(null); setSelectedWatchId(measurement.id);}} style={styles.measurementRow} accessibilityRole="button">{new Date(measurement.endedAt).toLocaleString('fr-FR', {day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit'})}APPLE WATCH{formatMetric(measurement.averageBpm, 'BPM')}Qualité {Math.round(measurement.quality * 100)} %{measurement.device.name} · couverture {Math.round(measurement.coverage * 100)} % · PRV non disponible · toucher pour le détail)} + + + Gestion des données + Conservation automatique + {([ + ['30days', '30 jours'], ['90days', '90 jours'], ['1year', '1 an'], ['forever', 'Sans limite'], + ] as const).map(([policy, label]) => onRetentionPolicyChange(policy)} style={[styles.policy, retentionPolicy === policy && styles.filterActive]}>{label})} + shareExport('json')} /> shareExport('csv')} /> + + + + + ); +} + +const styles = StyleSheet.create({ + content: {padding: spacing.lg, paddingBottom: spacing.xxl, gap: spacing.md}, + eyebrow: {color: colors.accent, fontSize: 10, fontWeight: '800', letterSpacing: 1.3}, + title: {color: colors.text, fontSize: 38, fontWeight: '800', letterSpacing: -1.2}, + subtitle: {color: colors.muted, lineHeight: 21}, + sectionTitle: {color: colors.text, fontSize: 20, fontWeight: '800'}, + row: {flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: spacing.sm}, + filterRow: {flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm}, + filter: {minWidth: '29%', flexGrow: 1, padding: spacing.sm, borderWidth: 1, borderColor: colors.border, borderRadius: radii.pill, alignItems: 'center'}, + filterActive: {backgroundColor: colors.accentSoft, borderColor: colors.accent}, + filterText: {color: colors.muted, fontWeight: '700', fontSize: 12}, + filterTextActive: {color: colors.text}, + summaryCard: {padding: spacing.lg, gap: spacing.sm, borderRadius: radii.lg, borderWidth: 1, borderColor: colors.border, backgroundColor: colors.surface}, + chartTitle: {color: colors.muted, fontSize: 11, fontWeight: '700', marginTop: spacing.xs}, + chart: {height: 64, flexDirection: 'row', alignItems: 'flex-end', gap: 4, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border}, + barSlot: {flex: 1, height: 64, justifyContent: 'flex-end'}, + bar: {width: '100%', minWidth: 3, borderTopLeftRadius: 3, borderTopRightRadius: 3}, + caption: {color: colors.muted, fontSize: 10, lineHeight: 15}, + detailCard: {padding: spacing.lg, gap: spacing.md, borderWidth: 1, borderColor: colors.accent, borderRadius: radii.lg, backgroundColor: colors.surfaceElevated}, + detailTitle: {color: colors.text, fontSize: 16, fontWeight: '800', marginTop: 4}, + close: {color: colors.accent, fontWeight: '700'}, + detailGrid: {flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm}, + metricTile: {width: '47%', padding: spacing.md, borderRadius: radii.md, backgroundColor: colors.surface}, + metricLabel: {color: colors.muted, fontSize: 10}, + metricValue: {color: colors.text, fontSize: 18, fontWeight: '800', marginTop: 4}, + detailText: {color: colors.muted, lineHeight: 20}, + regulationCard: {padding: spacing.md, gap: spacing.xs, borderWidth: 1, borderColor: colors.good, borderRadius: radii.md, backgroundColor: colors.surface}, + measurementRow: {paddingVertical: spacing.md, gap: spacing.sm, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: colors.border}, + date: {color: colors.text, fontWeight: '600'}, + cameraTag: {color: colors.accent, fontSize: 10, fontWeight: '800'}, + measurementTitle: {color: colors.text, fontSize: 21, fontWeight: '800'}, + measurementValue: {color: colors.good, fontWeight: '700'}, + dataCard: {padding: spacing.lg, gap: spacing.md, borderRadius: radii.lg, backgroundColor: colors.surfaceElevated}, + policyWrap: {flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm}, + policy: {paddingHorizontal: spacing.md, paddingVertical: spacing.sm, borderWidth: 1, borderColor: colors.border, borderRadius: radii.pill}, + exportRow: {gap: spacing.sm}, +}); diff --git a/src/features/ppg/screens/PPGMeasurementScreen.tsx b/src/features/ppg/screens/PPGMeasurementScreen.tsx new file mode 100644 index 0000000..6e63a6d --- /dev/null +++ b/src/features/ppg/screens/PPGMeasurementScreen.tsx @@ -0,0 +1,309 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import {ActivityIndicator, Pressable, StyleSheet, Text, View} from 'react-native'; +import {StopCircle} from 'phosphor-react-native'; +import type {PPGFrameUpdate} from 'react-native-haptika-ppg'; + +import {colors} from '../../../theme'; +import {PPGCameraView} from '../camera/PPGCameraView'; +import type {PPGCameraError} from '../camera/cameraTypes'; +import {HaptikaHeader} from '../components/HaptikaChrome'; +import type {MeasurementState} from '../measurement/measurementTypes'; + +interface PPGMeasurementScreenProps { + state: MeasurementState; + latestFrame: PPGFrameUpdate | null; + phaseProgress: number; + isCameraActive: boolean; + torchEnabled: boolean; + cameraPosition: 'back' | 'front'; + onCameraStarted: () => void; + onFrameUpdate: (update: PPGFrameUpdate) => void; + onInterrupted: (reason: string) => void; + onCameraError: (error: PPGCameraError) => void; + onCancel: () => void; +} + +const BAR_COUNT = 5; + +function statusCopy(state: MeasurementState): {title: string; subtitle: string} { + if (state === 'processing') { + return { + title: 'Analyse en cours', + subtitle: 'Nous préparons votre résultat', + }; + } + if (state === 'preparingCamera') { + return { + title: 'Initialisation', + subtitle: 'Gardez votre doigt en place', + }; + } + if (state === 'waitingForFinger') { + return { + title: 'Recherche du signal', + subtitle: 'Posez doucement votre doigt', + }; + } + return { + title: 'Mesure en cours', + subtitle: 'Ne bougez pas et respirez\nnaturellement', + }; +} + +function qualityLabel(score: number): string { + if (score >= 0.78) return 'EXCELLENT'; + if (score >= 0.55) return 'BON'; + if (score >= 0.32) return 'MOYEN'; + return 'EN COURS'; +} + +export function PPGMeasurementScreen({ + state, + latestFrame, + phaseProgress, + isCameraActive, + torchEnabled, + cameraPosition, + onCameraStarted, + onFrameUpdate, + onInterrupted, + onCameraError, + onCancel, +}: PPGMeasurementScreenProps): React.JSX.Element { + const [pulse, setPulse] = useState(0); + const isProcessing = state === 'processing'; + const progress = Math.min(Math.max(phaseProgress, 0), 1); + const displayedProgress = Math.round(progress * 100); + const confidence = Math.min( + Math.max(latestFrame?.contactConfidence ?? (state === 'measuring' ? 0.8 : 0.25), 0), + 1, + ); + const copy = statusCopy(state); + const activeDots = Math.max(1, Math.round(confidence * 5)); + const barHeights = useMemo( + () => [13, 23, 18, 28, 20].map((height, index) => height - ((pulse + index) % 3) * 2), + [pulse], + ); + + useEffect(() => { + const interval = setInterval(() => setPulse(value => value + 1), 420); + return () => clearInterval(interval); + }, []); + + return ( + + + + + + + + + + ENREGISTREMENT DIRECT + + {copy.title} + {copy.subtitle} + + + + + {isProcessing ? ( + + ) : ( + + {barHeights.map((height, index) => ( + + ))} + + )} + + + + + + + SIGNAL + + {Array.from({length: BAR_COUNT}, (_, index) => ( + = activeDots && styles.qualityDotInactive, + ]} + /> + ))} + + {qualityLabel(confidence)} + + + + [ + styles.stopButton, + pressed && styles.pressed, + isProcessing && styles.disabled, + ]}> + + + {isProcessing ? 'Analyse' : 'Arrêter'} + + + + Annuler + + + + + + PROGRESSION DE L’ANALYSE + {displayedProgress}% + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: {flex: 1, backgroundColor: '#071321'}, + opaqueCanvas: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: '#071321', + }, + measurementMain: {height: 496, alignItems: 'center'}, + headingBlock: {alignItems: 'center', paddingTop: 32}, + liveRow: {height: 22, flexDirection: 'row', alignItems: 'center'}, + liveDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#7DE2FF', + shadowColor: colors.accent, + shadowOpacity: 0.9, + shadowRadius: 7, + }, + liveLabel: { + color: colors.accent, + fontSize: 10, + fontWeight: '800', + letterSpacing: 2.1, + marginLeft: 8, + }, + measurementTitle: { + color: '#DFE6F1', + fontSize: 23, + lineHeight: 28, + fontWeight: '400', + marginTop: 8, + }, + measurementSubtitle: { + color: '#A6AFBE', + fontSize: 16, + lineHeight: 20, + textAlign: 'center', + marginTop: 6, + }, + signalHalo: { + width: 256, + height: 256, + borderRadius: 128, + borderWidth: 1, + borderColor: 'rgba(27,74,107,0.20)', + marginTop: 32, + alignItems: 'center', + justifyContent: 'center', + }, + signalCircle: { + width: 224, + height: 224, + borderRadius: 112, + borderWidth: 1, + borderColor: '#234D6B', + alignItems: 'center', + justifyContent: 'center', + }, + liveBars: {height: 32, flexDirection: 'row', alignItems: 'flex-end', gap: 4}, + liveBar: {width: 4, borderRadius: 2, backgroundColor: '#62D4FF'}, + controls: { + flex: 1, + alignItems: 'center', + paddingHorizontal: 38, + paddingTop: 0, + paddingBottom: 20, + }, + qualityPill: { + width: 230, + height: 33, + borderRadius: 17, + borderWidth: 1, + borderColor: 'rgba(23,70,111,0.7)', + backgroundColor: 'rgba(5,19,34,0.85)', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + signalLabel: {color: '#7F8A9A', fontSize: 10, letterSpacing: 1.1}, + qualityDots: {flexDirection: 'row', gap: 6, marginHorizontal: 18}, + qualityDot: {width: 6, height: 6, borderRadius: 3, backgroundColor: colors.accent}, + qualityDotInactive: {backgroundColor: '#24465E'}, + qualityText: {color: colors.accent, fontSize: 10, fontWeight: '800'}, + stopSection: {height: 140, alignItems: 'center', paddingTop: 40}, + stopButton: { + width: 165, + height: 56, + borderRadius: 13, + borderWidth: 1, + borderColor: '#1D3B58', + backgroundColor: '#142236', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 12, + }, + stopText: {color: '#CDD5E1', fontSize: 16}, + cancelButton: {height: 40, alignItems: 'center', justifyContent: 'center'}, + cancelText: {color: '#D0D7E1', fontSize: 12}, + progressBlock: {width: '100%', marginTop: 'auto'}, + progressLabels: { + height: 24, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 4, + }, + progressLabel: {color: '#526070', fontSize: 9, letterSpacing: 1.2}, + progressValue: { + color: '#59BDEB', + fontSize: 16, + lineHeight: 24, + fontVariant: ['tabular-nums'], + }, + progressTrack: {height: 2, backgroundColor: '#10283A', overflow: 'hidden'}, + progressFill: {height: 2, backgroundColor: '#62D4FF'}, + pressed: {opacity: 0.76, transform: [{scale: 0.99}]}, + disabled: {opacity: 0.55}, +}); diff --git a/src/features/ppg/screens/PPGPreparationScreen.tsx b/src/features/ppg/screens/PPGPreparationScreen.tsx new file mode 100644 index 0000000..714de13 --- /dev/null +++ b/src/features/ppg/screens/PPGPreparationScreen.tsx @@ -0,0 +1,658 @@ +import React, {useState} from 'react'; +import { + Image, + Modal, + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + View, +} from 'react-native'; +import { + Armchair, + Camera, + Hand, + MinusCircle, + WarningCircle, + Wind, + X, +} from 'phosphor-react-native'; + +import {colors, radii, spacing} from '../../../theme'; +import { + BottomNavigation, + HaptikaHeader, +} from '../components/HaptikaChrome'; +import type {BaselineCalculationResult} from '../stress/baselineTypes'; + +const placementImage = require('../assets/finger-camera-placement.png'); +const silhouetteImage = require('../assets/hrv-silhouette.png'); + +interface PPGPreparationScreenProps { + includeHealthKit: boolean; + baselineProgress: BaselineCalculationResult | null; + postExerciseProtocolTitle: string | null; + onIncludeHealthKitChange: (value: boolean) => void; + sensitiveContact: boolean; + onSensitiveContactChange: (value: boolean) => void; + cameraPosition: 'back' | 'front'; + onCameraPositionChange: (value: 'back' | 'front') => void; + atRest: boolean | null; + onAtRestChange: (value: boolean) => void; + onStart: (atRestOverride?: boolean) => void; + onOpenHistory: () => void; + onOpenCardiacMirror: () => void; + onOpenHapticExercise: () => void; + isStarting: boolean; +} + +type InstructionIcon = React.ComponentType<{ + color?: string; + size?: number; + weight?: 'thin' | 'light' | 'regular' | 'bold' | 'fill' | 'duotone'; +}>; + +const instructions: readonly {label: string; icon: InstructionIcon}[] = [ + {label: 'Asseyez-vous confortablement', icon: Armchair}, + {label: 'Posez doucement votre doigt', icon: Hand}, + {label: 'Gardez la main immobile', icon: MinusCircle}, + {label: 'Respirez naturellement', icon: Wind}, +]; + +export function PPGPreparationScreen({ + includeHealthKit, + postExerciseProtocolTitle, + onIncludeHealthKitChange, + sensitiveContact, + onSensitiveContactChange, + cameraPosition, + onCameraPositionChange, + onAtRestChange, + onStart, + onOpenHistory, + onOpenCardiacMirror, + onOpenHapticExercise, + isStarting, +}: PPGPreparationScreenProps): React.JSX.Element { + const [screen, setScreen] = useState<'home' | 'preparation'>('home'); + const [settingsOpen, setSettingsOpen] = useState(false); + + const openPreparation = (): void => { + onAtRestChange(true); + setScreen('preparation'); + }; + + const beginMeasurement = (): void => { + onAtRestChange(true); + onStart(true); + }; + + return ( + + setScreen('home')} + onSettingsPress={() => setSettingsOpen(true)} + /> + + {screen === 'home' ? ( + <> + + + MESURE RAPIDE + Lisez votre VFC à la demande + + Découvrez l’état de votre système{`\n`}nerveux autonome en temps réel. + + + + + + SYMPATHIQUE + PARASYMPATHIQUE + + + + + + + + + {postExerciseProtocolTitle !== null ? ( + + MESURE APRÈS EXERCICE + + Comparez maintenant l’effet de {postExerciseProtocolTitle}. + + + ) : null} + + [styles.measureCard, pressed && styles.pressed]}> + + + + + Mesurer ma VFC + + Placez votre doigt sur la caméra avant + + + + 60S + + + + La variabilité de la fréquence cardiaque (VFC) est un indicateur + clé de votre capacité de récupération et de gestion du stress. + + + [styles.mainCta, pressed && styles.pressed]}> + COMMENCER LA MESURE + + + + + ) : ( + <> + + + Préparez-vous + + PHASE DE CALIBRAGE BIOMÉTRIQUE + + + + + + + + + {instructions.map(item => { + const Icon = item.icon; + return ( + + + + + {item.label} + + ); + })} + + + + + + Tout mouvement peut réduire la qualité du signal. + + + + {cameraPosition === 'back' ? ( + + Le flash peut chauffer légèrement. Retirez votre doigt si la + chaleur devient inconfortable. + + ) : null} + + + + [styles.readyButton, pressed && styles.pressed]}> + + {isStarting ? 'PRÉPARATION…' : 'JE SUIS PRÊT'} + + + setScreen('home')} + style={styles.cancelButton}> + Annuler + + + + )} + + setSettingsOpen(false)} + onIncludeHealthKitChange={onIncludeHealthKitChange} + onSensitiveContactChange={onSensitiveContactChange} + onCameraPositionChange={onCameraPositionChange} + /> + + ); +} + +function SettingsModal({ + visible, + includeHealthKit, + sensitiveContact, + cameraPosition, + onClose, + onIncludeHealthKitChange, + onSensitiveContactChange, + onCameraPositionChange, +}: { + visible: boolean; + includeHealthKit: boolean; + sensitiveContact: boolean; + cameraPosition: 'back' | 'front'; + onClose: () => void; + onIncludeHealthKitChange: (value: boolean) => void; + onSensitiveContactChange: (value: boolean) => void; + onCameraPositionChange: (value: 'back' | 'front') => void; +}): React.JSX.Element { + return ( + + + undefined}> + + + MESURE VFC + Réglages + + + + + + Caméra utilisée + + {(['front', 'back'] as const).map(position => ( + onCameraPositionChange(position)} + style={[ + styles.cameraSegment, + cameraPosition === position && styles.cameraSegmentSelected, + ]}> + + {position === 'front' ? 'Avant' : 'Arrière + flash'} + + + ))} + + + + + + + ); +} + +function SettingSwitch({ + label, + description, + value, + onValueChange, +}: { + label: string; + description: string; + value: boolean; + onValueChange: (value: boolean) => void; +}): React.JSX.Element { + return ( + + + {label} + {description} + + + + ); +} + +const styles = StyleSheet.create({ + root: {flex: 1, backgroundColor: '#071321'}, + scrollArea: {flex: 1}, + homeContent: { + flexGrow: 1, + paddingHorizontal: 20, + paddingTop: 32, + paddingBottom: 24, + alignItems: 'center', + }, + homeIntro: {width: '100%', alignItems: 'center'}, + homeKicker: { + color: colors.accent, + fontSize: 10, + lineHeight: 14, + fontWeight: '800', + letterSpacing: 1.25, + }, + homeTitle: { + marginTop: 8, + color: '#E0E7F2', + fontSize: 23, + lineHeight: 28, + fontWeight: '400', + textAlign: 'center', + }, + homeSubtitle: { + marginTop: 10, + color: '#B7C0CE', + fontSize: 16, + lineHeight: 20, + textAlign: 'center', + }, + axisBlock: {width: 320, marginTop: 34}, + axisLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + sympathetic: { + color: '#F05869', + fontSize: 10, + fontWeight: '800', + letterSpacing: 1.25, + }, + parasympathetic: { + color: '#3187B8', + fontSize: 10, + fontWeight: '800', + letterSpacing: 1.25, + }, + axisTrack: { + marginTop: 16, + marginHorizontal: 16, + height: 4, + borderRadius: 3, + backgroundColor: '#63D6FF', + borderLeftWidth: 96, + borderLeftColor: '#F55B68', + }, + axisCursor: { + position: 'absolute', + left: '64%', + top: -7, + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: '#EDF4FC', + borderWidth: 2, + borderColor: '#162435', + shadowColor: '#000', + shadowOpacity: 0.45, + shadowRadius: 4, + }, + silhouette: {width: 280, height: 330, marginTop: 8}, + afterExerciseBanner: { + width: '100%', + borderWidth: 1, + borderColor: colors.good, + borderRadius: 12, + padding: 10, + marginBottom: 10, + }, + afterExerciseLabel: { + color: colors.good, + fontSize: 9, + fontWeight: '800', + letterSpacing: 1, + }, + afterExerciseText: {color: colors.text, fontSize: 12, marginTop: 3}, + measureCard: { + width: '100%', + minHeight: 72, + borderRadius: 14, + borderWidth: 1, + borderColor: '#17466F', + backgroundColor: 'rgba(7,28,46,0.96)', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + }, + cameraBadge: { + width: 38, + height: 38, + borderRadius: 7, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#10314B', + }, + measureCopy: {flex: 1, marginLeft: 16}, + measureTitle: {color: '#DCE4F1', fontSize: 17, fontWeight: '700'}, + measureSubtitle: {color: '#B2BDCA', fontSize: 12, marginTop: 2}, + durationBadge: { + minWidth: 30, + height: 20, + borderRadius: 5, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#12314A', + }, + durationText: {color: colors.accent, fontSize: 10, fontWeight: '800'}, + definition: { + width: '100%', + color: '#8997A9', + fontSize: 12, + lineHeight: 19, + fontStyle: 'italic', + paddingHorizontal: 16, + marginTop: 12, + }, + mainCta: { + width: '100%', + height: 44, + borderRadius: 13, + backgroundColor: '#75D3FA', + alignItems: 'center', + justifyContent: 'center', + marginTop: 28, + }, + mainCtaText: { + color: '#071321', + fontSize: 10, + fontWeight: '900', + letterSpacing: 2.25, + }, + preparationContent: { + paddingHorizontal: 20, + paddingTop: 32, + paddingBottom: 156, + alignItems: 'center', + }, + preparationHeading: {alignItems: 'center'}, + preparationTitle: { + color: '#DBE4F0', + fontSize: 29, + lineHeight: 34, + fontWeight: '300', + }, + preparationSubtitle: { + color: '#8F9AA9', + fontSize: 12, + lineHeight: 16, + letterSpacing: 0.4, + marginTop: 8, + }, + placementHalo: { + width: 280, + height: 280, + marginTop: 32, + borderRadius: 140, + padding: 12, + backgroundColor: 'rgba(28,82,119,0.20)', + shadowColor: colors.accent, + shadowOpacity: 0.13, + shadowRadius: 22, + }, + placementImage: {width: 256, height: 256, borderRadius: 128}, + instructionList: {width: '100%', gap: 12, marginTop: 32}, + instructionRow: { + height: 56, + borderRadius: 12, + borderWidth: 1, + borderColor: '#17466F', + backgroundColor: 'rgba(6,24,41,0.96)', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + }, + instructionIcon: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: '#0D2D47', + alignItems: 'center', + justifyContent: 'center', + }, + instructionText: {color: '#D5DEEB', fontSize: 15, marginLeft: 16}, + motionWarning: { + minHeight: 42, + alignSelf: 'stretch', + marginHorizontal: 13, + marginTop: 20, + borderRadius: 7, + borderWidth: 1, + borderColor: '#603246', + backgroundColor: '#1D131E', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + gap: 12, + }, + motionWarningText: {color: '#C3A7B2', fontSize: 12, flex: 1}, + thermalWarning: { + color: '#D7B2A0', + fontSize: 11, + lineHeight: 16, + marginTop: 10, + textAlign: 'center', + }, + preparationFooter: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: 100, + paddingHorizontal: 20, + paddingTop: 8, + backgroundColor: '#071321', + }, + readyButton: { + height: 52, + borderRadius: 12, + backgroundColor: '#75D3FA', + alignItems: 'center', + justifyContent: 'center', + shadowColor: colors.accent, + shadowOpacity: 0.25, + shadowRadius: 14, + shadowOffset: {width: 0, height: 8}, + }, + readyButtonText: { + color: '#071321', + fontSize: 10, + fontWeight: '900', + letterSpacing: 2.2, + }, + cancelButton: {height: 40, alignItems: 'center', justifyContent: 'center'}, + cancelText: {color: '#D3DCE8', fontSize: 12}, + pressed: {opacity: 0.76, transform: [{scale: 0.99}]}, + modalBackdrop: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,4,12,0.72)', + }, + settingsSheet: { + borderTopLeftRadius: radii.lg, + borderTopRightRadius: radii.lg, + backgroundColor: '#0A1A2B', + borderTopWidth: 1, + borderColor: colors.border, + padding: spacing.lg, + paddingBottom: 42, + }, + settingsHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: spacing.lg, + }, + settingsKicker: { + color: colors.accent, + fontSize: 9, + fontWeight: '800', + letterSpacing: 1.2, + }, + settingsTitle: {color: colors.text, fontSize: 27, fontWeight: '600'}, + settingLabel: {color: colors.muted, fontSize: 12, marginBottom: 8}, + cameraSegments: {flexDirection: 'row', gap: 8, marginBottom: 16}, + cameraSegment: { + flex: 1, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center', + justifyContent: 'center', + }, + cameraSegmentSelected: {backgroundColor: '#123B58', borderColor: colors.accent}, + cameraSegmentText: {color: colors.muted, fontWeight: '700'}, + cameraSegmentTextSelected: {color: colors.text}, + settingRow: { + minHeight: 72, + flexDirection: 'row', + alignItems: 'center', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + }, + settingCopy: {flex: 1, paddingRight: 12}, + settingTitle: {color: colors.text, fontWeight: '700'}, + settingDescription: {color: colors.muted, fontSize: 12, marginTop: 3}, +}); diff --git a/src/features/ppg/screens/PPGResultScreen.tsx b/src/features/ppg/screens/PPGResultScreen.tsx new file mode 100644 index 0000000..d9c50f0 --- /dev/null +++ b/src/features/ppg/screens/PPGResultScreen.tsx @@ -0,0 +1,1070 @@ +import React, {useMemo, useState} from 'react'; +import {Pressable, ScrollView, StyleSheet, Text, View} from 'react-native'; +import {useSafeAreaInsets} from 'react-native-safe-area-context'; + +import {colors, radii, spacing} from '../../../theme'; +import { + getRegulationProtocol, + type HapticRegulationProtocolKind, +} from '../../cardiacMirror/regulationProtocols'; +import type {RegulationComparison} from '../../cardiacMirror/regulationSessionTypes'; +import {evaluateHealthKitContext} from '../health/healthContextEvaluator'; +import type { + HealthKitContext, + HealthMetricEvaluation, + HealthMetricSample, +} from '../health/healthTypes'; +import type {PPGProcessingResult} from '../measurement/measurementTypes'; +import {ConfidenceIndicator} from '../components/ConfidenceIndicator'; +import {PotentialStressGauge} from '../components/PotentialStressGauge'; +import {PrimaryButton} from '../components/PrimaryButton'; +import {MetricInsightCard} from '../components/MetricInsightCard'; +import {PPGResultSummary} from '../components/PPGResultSummary'; +import {buildMetricInsights} from '../insights/metricInsights'; +import {interpretPotentialStressIndex} from '../stress/stressInterpretation'; +import {getBaselineStatus} from '../stress/baselineCalculator'; +import type {BaselineCalculationResult} from '../stress/baselineTypes'; +import type {PotentialStressResult} from '../stress/stressTypes'; +import type { + FeelingScore, + MeasurementSelfReport, + StoredMeasurement, +} from '../storage/storageTypes'; + +interface PPGResultScreenProps { + cameraResult: PPGProcessingResult; + measurementHistory: readonly StoredMeasurement[]; + potentialStress: PotentialStressResult; + healthKitContext: HealthKitContext | null; + baselineProgress: BaselineCalculationResult | null; + regulationComparison: RegulationComparison | null; + recommendedProtocolKind: HapticRegulationProtocolKind; + selfReportSaved: boolean; + selfReportSaving: boolean; + selfReportError: boolean; + onSaveSelfReport: (report: MeasurementSelfReport) => void; + onStartRegulation: (protocolKind: HapticRegulationProtocolKind) => void; + onRestart: () => void; + onDone: () => void; +} + +const feelingLabels: Record = { + 1: 'Très calme', + 2: 'Plutôt calme', + 3: 'Neutre', + 4: 'Sous tension', + 5: 'Très tendu·e', +}; + +const healthSampleDate = (date: string): string => + new Date(date).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + }); + +const healthSampleAge = (date: string, now = new Date()): string => { + const seconds = Math.max(0, Math.round((now.getTime() - Date.parse(date)) / 1_000)); + if (!Number.isFinite(seconds)) return 'date inconnue'; + if (seconds < 60) return `il y a ${seconds} s`; + if (seconds < 3_600) return `il y a ${Math.floor(seconds / 60)} min`; + if (seconds < 86_400) { + const hours = Math.floor(seconds / 3_600); + const minutes = Math.floor((seconds % 3_600) / 60); + return `il y a ${hours} h ${minutes} min`; + } + const days = Math.floor(seconds / 86_400); + const hours = Math.floor((seconds % 86_400) / 3_600); + return `il y a ${days} j ${hours} h`; +}; + +const relevanceLabel = ( + evaluation: HealthMetricEvaluation | null | undefined, +): string => { + switch (evaluation?.relevance) { + case 'freshContext': + return 'pertinence élevée'; + case 'recentContext': + return 'pertinence modérée'; + case 'stale': + return 'ancienne · non utilisée'; + case 'futureDated': + case 'invalid': + return 'non exploitable'; + default: + return 'pertinence inconnue'; + } +}; + +interface DailyHealthTrend { + day: string; + average: number; + count: number; +} + +const dailyHeartRateTrends = ( + samples: readonly HealthMetricSample[], +): readonly DailyHealthTrend[] => { + const groups = new Map(); + samples.forEach(sample => { + const timestamp = Date.parse(sample.endDate); + if (!Number.isFinite(timestamp) || !Number.isFinite(sample.value)) return; + const day = new Date(timestamp).toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'short', + }); + groups.set(day, [...(groups.get(day) ?? []), sample.value]); + }); + return [...groups.entries()].slice(0, 7).map(([day, values]) => ({ + day, + average: values.reduce((sum, value) => sum + value, 0) / values.length, + count: values.length, + })); +}; + +export function PPGResultScreen({ + cameraResult, + measurementHistory, + potentialStress, + healthKitContext, + baselineProgress, + regulationComparison, + recommendedProtocolKind, + selfReportSaved, + selfReportSaving, + selfReportError, + onSaveSelfReport, + onStartRegulation, + onRestart, + onDone, +}: PPGResultScreenProps): React.JSX.Element { + const insets = useSafeAreaInsets(); + const interpretation = interpretPotentialStressIndex(potentialStress.index); + const [showReport, setShowReport] = useState(false); + const [showTechnicalDetails, setShowTechnicalDetails] = useState(false); + const [feeling, setFeeling] = useState(null); + const [posture, setPosture] = useState<'seated' | 'lying' | null>(null); + const [recentExercise, setRecentExercise] = useState(null); + const [recentCaffeine, setRecentCaffeine] = useState(null); + const [recentMeal, setRecentMeal] = useState(null); + const [movement, setMovement] = useState(null); + const [warmPhone, setWarmPhone] = useState(null); + + const baselineMeasurementCount = + baselineProgress?.baseline?.validMeasurementCount ?? 0; + const baselineRemaining = Math.max(0, 5 - baselineMeasurementCount); + const baselineStatus = getBaselineStatus(baselineMeasurementCount); + const recommendedProtocol = getRegulationProtocol(recommendedProtocolKind); + const healthEvaluation = useMemo( + () => + healthKitContext === null + ? null + : evaluateHealthKitContext(healthKitContext), + [healthKitContext], + ); + const healthTrends = useMemo( + () => dailyHeartRateTrends(healthKitContext?.recentHeartRateSamples ?? []), + [healthKitContext], + ); + const metricInsights = useMemo( + () => buildMetricInsights(cameraResult, measurementHistory), + [cameraResult, measurementHistory], + ); + const primaryInsights = metricInsights.filter( + insight => insight.key === 'bpm' || insight.key === 'rmssd', + ); + const advancedInsights = metricInsights.filter( + insight => insight.key !== 'bpm' && insight.key !== 'rmssd', + ); + + return ( + + onStartRegulation(recommendedProtocolKind)} + onDone={onDone} + /> + + {!cameraResult.prv.reliable ? ( + + Pouls exploitable + + Le BPM moyen est utilisable pour cette mesure. La variabilité entre + les battements (PRV) n’est pas assez stable et n’est pas utilisée + pour calculer un indice de stress. + + + ) : null} + + + VOS TENDANCES + Rapport de la mesure + + Chaque point correspond à une vraie mesure caméra. Les repères sont + personnels et descriptifs, jamais des normes médicales. + + {primaryInsights.map(insight => ( + + ))} + + + {showTechnicalDetails && cameraResult.prv.reliable ? ( + + RAPPORT PRV EXPÉRIMENTAL + Intervalles pulsatoires + + Calculs dérivés des intervalles entre pulsations. Ces estimations + caméra ne remplacent pas une HRV mesurée par ECG. + + {advancedInsights.map(insight => ( + + ))} + + ) : null} + + {regulationComparison !== null ? ( + + AVANT · APRÈS EXERCICE + Votre réponse observée + + Évolution entre deux mesures caméra. Elle décrit cette séance sans + conclure à un effet médical. + + + + + + + + {regulationComparison.limitations.includes('prvUnavailable') ? ( + + La PRV n’était pas assez fiable sur les deux mesures : seul le + pouls est comparé. + + ) : null} + + ) : null} + + + + {baselineStatus === 'building' ? ( + + + {baselineMeasurementCount}/5 mesures comparables + + + {baselineRemaining === 0 + ? 'Votre baseline provisoire sera utilisée lors de la prochaine mesure.' + : `Encore ${baselineRemaining} mesure${baselineRemaining > 1 ? 's' : ''} valide${baselineRemaining > 1 ? 's' : ''} au repos avant une estimation provisoire.`} + + + ) : ( + <> + + + {baselineStatus === 'personalized' + ? 'Repère personnel établi' + : 'Repère personnel provisoire'} + + + {baselineMeasurementCount} mesures comparables + {baselineProgress?.baseline?.medianBpm !== undefined + ? ` · médiane ${Math.round(baselineProgress.baseline.medianBpm)} BPM` + : ''} + + + + + )} + + {showTechnicalDetails && healthKitContext !== null ? ( + + Contexte Apple Santé + + Source séparée de la PPG caméra · historique complémentaire uniquement, + jamais présenté comme mesure en temps réel. + + {healthKitContext.latestRestingHeartRate !== null ? ( + + ) : null} + {healthKitContext.latestHrvSdnn !== null ? ( + + ) : null} + {healthTrends.length > 0 ? ( + + Tendance FC · jusqu’à 7 jours + {healthTrends.map(trend => ( + + {trend.day} · moyenne {Math.round(trend.average)} bpm · {trend.count}{' '} + échantillon{trend.count > 1 ? 's' : ''} + + ))} + + ) : null} + {healthKitContext.recentHeartRateSamples.slice(0, 3).map((sample, index) => ( + + ))} + {healthKitContext.latestHeartbeatSeries !== null && + healthKitContext.latestHeartbeatSeries !== undefined ? ( + + Série de battements Apple Santé + + {healthKitContext.latestHeartbeatSeries.heartbeatTimesSinceSeriesStartSeconds.length}{' '} + battements · {healthKitContext.latestHeartbeatSeries.precededByGapIndexes.length}{' '} + interruption(s) · {healthSampleAge(healthKitContext.latestHeartbeatSeries.endDate)} + + + {healthKitContext.latestHeartbeatSeries.sourceName ?? 'Source inconnue'} · série + historique séparée, jamais fusionnée avec la caméra + + + ) : null} + {healthKitContext.latestRestingHeartRate === null && + healthKitContext.latestHrvSdnn === null && + healthKitContext.recentHeartRateSamples.length === 0 ? ( + + Aucune donnée accessible — autorisation refusée ou historique vide. + + ) : null} + + ) : null} + + setShowTechnicalDetails(value => !value)} + style={styles.detailsToggle}> + + Détails de la mesure + + Intervalles, Apple Santé et limites méthodologiques + + + {showTechnicalDetails ? '−' : '+'} + + + {interpretation.disclaimer} + + + {showReport && !selfReportSaved ? ( + + Comment vous sentez-vous actuellement ? + + {([1, 2, 3, 4, 5] as const).map(score => ( + setFeeling(score)} + style={[ + styles.feelingButton, + feeling === score && styles.feelingButtonSelected, + ]}> + + {score} + + + ))} + + + {feeling === null ? 'Choisissez une valeur' : feelingLabels[feeling]} + + + + + + + + { + if (feeling === null) return; + onSaveSelfReport({ + feeling, + posture, + recentExercise, + recentCaffeine, + recentMeal, + movementDuringMeasurement: movement, + phoneUnusuallyWarm: warmPhone, + recordedAt: new Date().toISOString(), + }); + }} + /> + + Votre ressenti est stocké séparément et ne modifie pas ce résultat. + + {selfReportError ? ( + + L’enregistrement local a échoué. Vérifiez l’espace disponible puis réessayez. + + ) : null} + + ) : null} + + {!showReport && !selfReportSaved ? ( + setShowReport(true)} + /> + ) : null} + {selfReportSaved ? ( + Ressenti enregistré séparément ✓ + ) : null} + + + + ); +} + +interface OptionalBooleanRowProps { + label: string; + value: boolean | null; + onChange: (value: boolean | null) => void; +} + +function HealthMetricRow({ + label, + sample, + evaluation, +}: { + label: string; + sample: HealthMetricSample; + evaluation: HealthMetricEvaluation | null | undefined; +}): React.JSX.Element { + const source = sample.sourceName ?? 'Source inconnue'; + const device = sample.deviceName ?? 'appareil non renseigné'; + return ( + + + {label} · {Math.round(sample.value)} {sample.type === 'hrvSdnn' ? 'ms' : 'bpm'} + + + {source} · {device} · {healthSampleAge(sample.endDate)} + + + {relevanceLabel(evaluation)} · {healthSampleDate(sample.endDate)} + + + ); +} + +function ComparisonMetric({ + label, + comparison, + unit, +}: { + label: string; + comparison: RegulationComparison['bpm']; + unit: string; +}): React.JSX.Element { + const signedDelta = + comparison === null + ? '—' + : `${comparison.delta > 0 ? '+' : ''}${comparison.delta.toFixed(0)}`; + return ( + + {label} + + {comparison === null + ? 'Indisponible' + : `${comparison.before.toFixed(0)} → ${comparison.after.toFixed(0)}`} + + + {signedDelta} {comparison === null ? '' : unit} + + + ); +} + +function OptionalBooleanRow({ + label, + value, + onChange, +}: OptionalBooleanRowProps): React.JSX.Element { + return ( + + ); +} + +interface ChoiceRowProps { + label: string; + choices: readonly {label: string; value: T}[]; + value: T | null; + onChange: (value: T | null) => void; +} + +function ChoiceRow({ + label, + choices, + value, + onChange, +}: ChoiceRowProps): React.JSX.Element { + return ( + + {label} + + {choices.map(choice => { + const selected = value === choice.value; + return ( + onChange(selected ? null : choice.value)} + style={[styles.choiceButton, selected && styles.choiceButtonSelected]}> + + {choice.label} + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + content: { + padding: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.lg, + }, + kicker: { + color: colors.accent, + fontSize: 10, + fontWeight: '800', + letterSpacing: 1.4, + }, + title: { + color: colors.text, + fontSize: 34, + lineHeight: 39, + fontWeight: '800', + letterSpacing: -1.2, + }, + timestamp: { + color: colors.muted, + fontSize: 12, + }, + vitalsRow: { + flexDirection: 'row', + gap: spacing.sm, + padding: spacing.sm, + borderRadius: radii.lg, + backgroundColor: colors.surface, + }, + bpmCard: { + flex: 1, + padding: spacing.md, + borderRadius: radii.md, + backgroundColor: colors.surfaceElevated, + borderWidth: 0, + }, + prvCard: { + flex: 1, + padding: spacing.md, + borderRadius: radii.md, + backgroundColor: colors.surface, + borderWidth: 0, + }, + cardEyebrow: { + color: colors.muted, + textTransform: 'uppercase', + letterSpacing: 0.7, + fontSize: 11, + fontWeight: '700', + }, + metricRow: { + flexDirection: 'row', + alignItems: 'baseline', + marginTop: spacing.sm, + }, + metricValue: { + color: colors.text, + fontSize: 43, + fontWeight: '800', + letterSpacing: -1.5, + fontVariant: ['tabular-nums'], + }, + metricValueSmall: { + color: colors.text, + fontSize: 34, + fontWeight: '800', + fontVariant: ['tabular-nums'], + }, + metricUnit: { + color: colors.muted, + fontSize: 11, + marginLeft: spacing.xs, + }, + metricSource: { + color: colors.muted, + fontSize: 9, + lineHeight: 13, + marginTop: spacing.xs, + }, + qualityRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + limitationCard: { + padding: spacing.md, + borderRadius: radii.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + limitationTitle: { + color: colors.text, + fontWeight: '800', + }, + limitationText: { + color: colors.muted, + lineHeight: 20, + marginTop: spacing.xs, + }, + intervalReport: { + padding: spacing.md, + borderRadius: radii.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + insightReport: { + paddingHorizontal: spacing.md, + paddingTop: spacing.lg, + borderRadius: radii.lg, + backgroundColor: '#08111F', + borderWidth: 1, + borderColor: colors.border, + overflow: 'hidden', + }, + insightReportTitle: { + color: colors.text, + fontSize: 24, + fontWeight: '800', + letterSpacing: -0.5, + }, + insightReportIntro: { + color: colors.muted, + fontSize: 12, + lineHeight: 18, + marginTop: spacing.xs, + marginBottom: spacing.sm, + }, + intervalIntro: { + color: colors.muted, + fontSize: 12, + lineHeight: 17, + marginTop: spacing.xs, + }, + intervalGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + marginTop: spacing.md, + }, + intervalMetric: { + width: '47%', + padding: spacing.sm, + borderRadius: radii.sm, + backgroundColor: colors.surfaceElevated, + }, + intervalLabel: { + color: colors.muted, + fontSize: 10, + fontWeight: '700', + }, + intervalValue: { + color: colors.text, + fontSize: 22, + fontWeight: '800', + marginTop: 3, + fontVariant: ['tabular-nums'], + }, + intervalUnit: { + color: colors.muted, + fontSize: 10, + fontWeight: '500', + }, + duration: { + color: colors.muted, + fontSize: 12, + }, + baselineCard: { + borderRadius: radii.md, + padding: spacing.md, + backgroundColor: '#171D21', + borderWidth: 1, + borderColor: '#2B4148', + }, + comparisonCard: { + padding: spacing.md, + borderRadius: radii.md, + borderWidth: 1, + borderColor: colors.good, + backgroundColor: '#072338', + }, + comparisonEyebrow: { + color: colors.good, + fontSize: 9, + fontWeight: '800', + letterSpacing: 1.2, + marginBottom: spacing.xs, + }, + comparisonIntro: { + color: colors.muted, + lineHeight: 20, + marginTop: spacing.xs, + }, + comparisonGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.md, + }, + comparisonMetric: { + width: '48%', + padding: spacing.sm, + borderRadius: radii.sm, + backgroundColor: colors.surfaceElevated, + }, + comparisonMetricLabel: { + color: colors.muted, + fontSize: 9, + fontWeight: '700', + }, + comparisonMetricValues: { + color: colors.text, + fontSize: 14, + fontWeight: '800', + marginTop: 4, + }, + comparisonDelta: { + color: colors.accent, + fontSize: 12, + fontWeight: '800', + marginTop: 3, + }, + comparisonLimitation: { + color: colors.medium, + fontSize: 11, + lineHeight: 16, + marginTop: spacing.sm, + }, + recommendationCard: { + padding: spacing.lg, + borderRadius: radii.lg, + borderWidth: 1, + borderColor: colors.accent, + backgroundColor: colors.surfaceElevated, + gap: spacing.md, + }, + recommendationTitle: { + color: colors.text, + fontSize: 24, + fontWeight: '800', + }, + recommendationText: { + color: colors.muted, + lineHeight: 20, + }, + baselineTitle: { + color: colors.blue, + fontWeight: '700', + }, + baselineText: { + color: colors.muted, + lineHeight: 19, + marginTop: spacing.xs, + }, + healthCard: { + padding: spacing.md, + borderRadius: radii.md, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + }, + healthMetricRow: { + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.border, + }, + healthTrendBlock: { + paddingVertical: spacing.sm, + gap: 3, + }, + healthTrendTitle: { + color: colors.text, + fontSize: 13, + fontWeight: '800', + marginBottom: 3, + }, + healthSource: { + color: colors.muted, + fontSize: 11, + lineHeight: 16, + marginTop: 2, + }, + healthRelevance: { + color: colors.accent, + fontSize: 11, + lineHeight: 16, + marginTop: 2, + }, + heartbeatSeriesCard: { + padding: spacing.sm, + borderRadius: radii.sm, + backgroundColor: colors.surfaceElevated, + marginTop: spacing.sm, + }, + sectionTitle: { + color: colors.text, + fontSize: 18, + fontWeight: '700', + }, + healthIntro: { + color: colors.muted, + fontSize: 12, + lineHeight: 17, + marginTop: spacing.xs, + }, + healthMetric: { + color: colors.text, + fontSize: 12, + lineHeight: 18, + marginTop: spacing.sm, + }, + disclaimer: { + padding: spacing.md, + borderLeftWidth: 2, + borderLeftColor: colors.medium, + backgroundColor: '#1A1713', + }, + disclaimerText: { + color: '#D4C5AE', + fontSize: 12, + lineHeight: 18, + }, + detailsToggle: { + minHeight: 68, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderRadius: radii.md, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + detailsTitle: { + color: colors.text, + fontSize: 15, + fontWeight: '800', + }, + detailsSubtitle: { + color: colors.muted, + fontSize: 11, + marginTop: 3, + }, + detailsChevron: { + color: colors.accent, + fontSize: 26, + fontWeight: '300', + }, + reportCard: { + padding: spacing.md, + borderRadius: radii.lg, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + }, + feelingRow: { + flexDirection: 'row', + gap: spacing.xs, + marginTop: spacing.md, + }, + feelingButton: { + flex: 1, + aspectRatio: 1, + borderRadius: radii.md, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surfaceElevated, + }, + feelingButtonSelected: { + borderColor: colors.accent, + backgroundColor: colors.accentSoft, + }, + feelingNumber: { + color: colors.muted, + fontWeight: '800', + }, + feelingNumberSelected: { + color: colors.accent, + }, + feelingLabel: { + color: colors.text, + textAlign: 'center', + marginVertical: spacing.sm, + fontWeight: '600', + }, + choiceRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + minHeight: 50, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.border, + }, + choiceLabel: { + color: colors.text, + flex: 1, + paddingRight: spacing.sm, + }, + choiceButtons: { + flexDirection: 'row', + gap: spacing.xs, + }, + choiceButton: { + minWidth: 62, + minHeight: 34, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.sm, + borderRadius: radii.sm, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surfaceElevated, + }, + choiceButtonSelected: { + borderColor: colors.accent, + backgroundColor: colors.accentSoft, + }, + choiceText: { + color: colors.muted, + fontSize: 12, + fontWeight: '600', + }, + choiceTextSelected: { + color: colors.accent, + }, + reportNote: { + color: colors.muted, + fontSize: 10, + textAlign: 'center', + marginTop: spacing.sm, + }, + reportError: { + color: colors.poor, + fontSize: 11, + lineHeight: 16, + textAlign: 'center', + marginTop: spacing.xs, + }, + saved: { + color: colors.good, + textAlign: 'center', + fontWeight: '600', + }, +}); diff --git a/src/features/ppg/storage/acquisitionPreferencesRepository.ts b/src/features/ppg/storage/acquisitionPreferencesRepository.ts new file mode 100644 index 0000000..569cfe9 --- /dev/null +++ b/src/features/ppg/storage/acquisitionPreferencesRepository.ts @@ -0,0 +1,43 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export interface AcquisitionPreferences { + cameraPosition: 'back' | 'front'; + sensitiveContact: boolean; +} + +const STORAGE_KEY = '@haptika/ppg/v1/acquisition-preferences'; + +export const DEFAULT_ACQUISITION_PREFERENCES: Readonly = + Object.freeze({ + cameraPosition: 'front', + sensitiveContact: true, + }); + +function parsePreferences(value: string | null): AcquisitionPreferences { + if (value === null) return {...DEFAULT_ACQUISITION_PREFERENCES}; + try { + const parsed = JSON.parse(value) as Partial; + return { + cameraPosition: + parsed.cameraPosition === 'back' || parsed.cameraPosition === 'front' + ? parsed.cameraPosition + : DEFAULT_ACQUISITION_PREFERENCES.cameraPosition, + sensitiveContact: + typeof parsed.sensitiveContact === 'boolean' + ? parsed.sensitiveContact + : DEFAULT_ACQUISITION_PREFERENCES.sensitiveContact, + }; + } catch { + return {...DEFAULT_ACQUISITION_PREFERENCES}; + } +} + +export const acquisitionPreferencesRepository = { + async get(): Promise { + return parsePreferences(await AsyncStorage.getItem(STORAGE_KEY)); + }, + + async set(preferences: AcquisitionPreferences): Promise { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + }, +}; diff --git a/src/features/ppg/storage/baselineRepository.ts b/src/features/ppg/storage/baselineRepository.ts new file mode 100644 index 0000000..7cfe2be --- /dev/null +++ b/src/features/ppg/storage/baselineRepository.ts @@ -0,0 +1,16 @@ +import { + calculateBaseline, + DEFAULT_BASELINE_CONFIGURATION, +} from '../stress/baselineCalculator'; +import type {BaselineCalculationResult} from '../stress/baselineTypes'; +import {measurementRepository, toBaselineMeasurement} from './measurementRepository'; + +export const baselineRepository = { + async calculate(): Promise { + const measurements = await measurementRepository.getAll(); + return calculateBaseline( + measurements.map(toBaselineMeasurement), + DEFAULT_BASELINE_CONFIGURATION, + ); + }, +}; diff --git a/src/features/ppg/storage/database.ts b/src/features/ppg/storage/database.ts new file mode 100644 index 0000000..4757548 --- /dev/null +++ b/src/features/ppg/storage/database.ts @@ -0,0 +1,24 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const namespace = '@haptika/ppg/v1'; + +export const storageKeys = { + measurements: `${namespace}/measurements`, + settings: `${namespace}/settings`, +} as const; + +export async function readJSON(key: string, fallback: T): Promise { + const stored = await AsyncStorage.getItem(key); + if (stored === null) { + return fallback; + } + try { + return JSON.parse(stored) as T; + } catch { + return fallback; + } +} + +export async function writeJSON(key: string, value: T): Promise { + await AsyncStorage.setItem(key, JSON.stringify(value)); +} diff --git a/src/features/ppg/storage/measurementRepository.ts b/src/features/ppg/storage/measurementRepository.ts new file mode 100644 index 0000000..a47051a --- /dev/null +++ b/src/features/ppg/storage/measurementRepository.ts @@ -0,0 +1,480 @@ +import type {BaselineMeasurement} from '../stress/baselineTypes'; +import type { + HealthHeartbeatSeriesSummary, + HealthKitContext, + HealthMetricSample, +} from '../health/healthTypes'; +import type { + BPMEstimate, + PPGProcessingResult, + PRVMetrics, + SignalQualityResult, +} from '../measurement/measurementTypes'; +import type {PotentialStressResult} from '../stress/stressTypes'; +import {readJSON, storageKeys, writeJSON} from './database'; +import type { + DataRetentionPolicy, + MeasurementSelfReport, + PPGDataPolicy, + StoredMeasurement, +} from './storageTypes'; + +type UnknownRecord = Record; + +const isRecord = (value: unknown): value is UnknownRecord => + typeof value === 'object' && value !== null && !Array.isArray(value); +const isFiniteNumber = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); +const isNullableFiniteNumber = (value: unknown): value is number | null => + value === null || isFiniteNumber(value); +const isNullableString = (value: unknown): value is string | null => + value === null || typeof value === 'string'; +const isValidDateString = (value: unknown): value is string => + typeof value === 'string' && Number.isFinite(Date.parse(value)); + +function isBPMEstimate(value: unknown): value is BPMEstimate { + if (!isRecord(value)) return false; + return ( + isNullableFiniteNumber(value.bpm) && + isNullableFiniteNumber(value.temporalBpm) && + isNullableFiniteNumber(value.frequencyBpm) && + isNullableFiniteNumber(value.methodAgreementBpm) && + isFiniteNumber(value.validPeakCount) && + isFiniteNumber(value.rejectedPeakCount) && + isFiniteNumber(value.confidence) && + typeof value.selectedChannel === 'string' + ); +} + +function isPRVMetrics(value: unknown): value is PRVMetrics { + if (!isRecord(value)) return false; + return ( + isNullableFiniteNumber(value.estimatedRmssdMs) && + isNullableFiniteNumber(value.estimatedSdnnMs) && + (value.meanIpiMs === undefined || isNullableFiniteNumber(value.meanIpiMs)) && + isNullableFiniteNumber(value.medianIpiMs) && + (value.pnn50Percent === undefined || isNullableFiniteNumber(value.pnn50Percent)) && + (value.rangeIpiMs === undefined || isNullableFiniteNumber(value.rangeIpiMs)) && + (value.modeIpiMs === undefined || isNullableFiniteNumber(value.modeIpiMs)) && + (value.amo50Percent === undefined || isNullableFiniteNumber(value.amo50Percent)) && + isNullableFiniteNumber(value.coefficientOfVariation) && + isFiniteNumber(value.validIntervalCount) && + isFiniteNumber(value.rejectedIntervalCount) && + isFiniteNumber(value.rejectedIntervalRatio) && + typeof value.reliable === 'boolean' + ); +} + +function isSignalQualityResult(value: unknown): value is SignalQualityResult { + if (!isRecord(value)) return false; + return ( + isFiniteNumber(value.score) && + ['good', 'medium', 'poor', 'invalid'].includes(String(value.level)) && + isFiniteNumber(value.usableDurationSeconds) && + isFiniteNumber(value.droppedFrameRatio) && + isFiniteNumber(value.saturationRatio) && + isFiniteNumber(value.rejectedIntervalRatio) && + isNullableFiniteNumber(value.signalToNoiseRatio) && + isFiniteNumber(value.motionArtifactRatio) && + isFiniteNumber(value.methodAgreementScore) && + isFiniteNumber(value.timestampJitterRatio) && + isFiniteNumber(value.longestGapSeconds) && + Array.isArray(value.issues) && + value.issues.every(issue => typeof issue === 'string') + ); +} + +function isProcessingResult(value: unknown): value is PPGProcessingResult { + if (!isRecord(value)) return false; + return ( + typeof value.valid === 'boolean' && + isNullableString(value.invalidReason) && + isBPMEstimate(value.bpm) && + isPRVMetrics(value.prv) && + isSignalQualityResult(value.quality) && + isFiniteNumber(value.startedAtMonotonicSeconds) && + isFiniteNumber(value.endedAtMonotonicSeconds) && + isFiniteNumber(value.rawSampleCount) && + isFiniteNumber(value.resampledSampleCount) + ); +} + +function isPotentialStressResult(value: unknown): value is PotentialStressResult { + if (!isRecord(value)) return false; + return ( + isNullableFiniteNumber(value.index) && + isFiniteNumber(value.confidence) && + ['unavailable', 'baselineBuilding', 'provisional', 'personalized'].includes( + String(value.status), + ) && + isNullableFiniteNumber(value.bpmContribution) && + isNullableFiniteNumber(value.prvContribution) && + isFiniteNumber(value.signalQuality) && + isFiniteNumber(value.baselineMeasurementCount) && + value.healthKitUsedForScore === false && + Array.isArray(value.limitations) && + value.limitations.every(limitation => typeof limitation === 'string') + ); +} + +function isHealthMetricSample(value: unknown): value is HealthMetricSample { + if (!isRecord(value)) return false; + return ( + ['heartRate', 'restingHeartRate', 'hrvSdnn'].includes(String(value.type)) && + isFiniteNumber(value.value) && + typeof value.unit === 'string' && + isValidDateString(value.startDate) && + isValidDateString(value.endDate) && + isNullableString(value.sourceName) && + isNullableString(value.sourceBundleIdentifier) && + isNullableString(value.deviceName) + ); +} + +function isHeartbeatSeriesSummary( + value: unknown, +): value is HealthHeartbeatSeriesSummary { + if (!isRecord(value)) return false; + return ( + isValidDateString(value.startDate) && + isValidDateString(value.endDate) && + isNullableString(value.sourceName) && + isNullableString(value.sourceBundleIdentifier) && + Array.isArray(value.heartbeatTimesSinceSeriesStartSeconds) && + value.heartbeatTimesSinceSeriesStartSeconds.every(isFiniteNumber) && + Array.isArray(value.precededByGapIndexes) && + value.precededByGapIndexes.every(isFiniteNumber) + ); +} + +function parseHealthKitContext(value: unknown): HealthKitContext | undefined { + if (!isRecord(value)) return undefined; + const authorizationRequestSettled = + typeof value.authorizationRequestSettled === 'boolean' + ? value.authorizationRequestSettled + : typeof value.authorized === 'boolean' + ? value.authorized + : undefined; + const valid = + typeof value.available === 'boolean' && + authorizationRequestSettled !== undefined && + (value.latestRestingHeartRate === null || + isHealthMetricSample(value.latestRestingHeartRate)) && + (value.latestHrvSdnn === null || isHealthMetricSample(value.latestHrvSdnn)) && + Array.isArray(value.recentHeartRateSamples) && + value.recentHeartRateSamples.every(isHealthMetricSample) && + (value.latestHeartbeatSeries === undefined || + value.latestHeartbeatSeries === null || + isHeartbeatSeriesSummary(value.latestHeartbeatSeries)) && + isFiniteNumber(value.contextConfidence) && + isValidDateString(value.fetchedAt); + if (!valid) return undefined; + return { + available: value.available as boolean, + authorizationRequestSettled, + latestRestingHeartRate: value.latestRestingHeartRate as HealthMetricSample | null, + latestHrvSdnn: value.latestHrvSdnn as HealthMetricSample | null, + recentHeartRateSamples: value.recentHeartRateSamples as HealthMetricSample[], + latestHeartbeatSeries: + value.latestHeartbeatSeries === undefined + ? null + : (value.latestHeartbeatSeries as HealthHeartbeatSeriesSummary | null), + contextConfidence: value.contextConfidence as number, + fetchedAt: value.fetchedAt as string, + }; +} + +function isSelfReport(value: unknown): value is MeasurementSelfReport { + if (!isRecord(value)) return false; + const isOptionalBoolean = (item: unknown): item is boolean | null => + item === null || typeof item === 'boolean'; + return ( + [1, 2, 3, 4, 5].includes(Number(value.feeling)) && + (value.posture === null || value.posture === 'seated' || value.posture === 'lying') && + isOptionalBoolean(value.recentExercise) && + isOptionalBoolean(value.recentCaffeine) && + isOptionalBoolean(value.recentMeal) && + isOptionalBoolean(value.movementDuringMeasurement) && + isOptionalBoolean(value.phoneUnusuallyWarm) && + isValidDateString(value.recordedAt) + ); +} + +function reportAllowsBaseline(report: MeasurementSelfReport | null): boolean { + if (report === null) return true; + return ![ + report.recentExercise, + report.recentCaffeine, + report.recentMeal, + report.movementDuringMeasurement, + report.phoneUnusuallyWarm, + ].some(value => value === true); +} + +function parseStoredMeasurement(value: unknown): StoredMeasurement | null { + if (!isRecord(value)) return null; + const selfReport = value.selfReport === null + ? null + : isSelfReport(value.selfReport) + ? value.selfReport + : undefined; + const healthKitContext = + value.healthKitContext === null + ? null + : parseHealthKitContext(value.healthKitContext); + if ( + value.schemaVersion !== 1 || + typeof value.id !== 'string' || + !isValidDateString(value.measuredAt) || + typeof value.atRest !== 'boolean' || + !isProcessingResult(value.cameraResult) || + !isPotentialStressResult(value.potentialStress) || + healthKitContext === undefined || + selfReport === undefined + ) { + return null; + } + + // Older v1 rows did not persist this derived field; infer it during migration. + const baselineEligible = + typeof value.baselineEligible === 'boolean' + ? value.baselineEligible + : value.atRest && reportAllowsBaseline(selfReport); + const rawAcquisition = isRecord(value.acquisition) ? value.acquisition : null; + const cameraPosition = + rawAcquisition?.cameraPosition === 'front' || rawAcquisition?.cameraPosition === 'back' + ? rawAcquisition.cameraPosition + : 'unknown'; + const torchUsed = + typeof rawAcquisition?.torchUsed === 'boolean' ? rawAcquisition.torchUsed : null; + return { + schemaVersion: 1, + ...(typeof value.algorithmVersion === 'string' + ? {algorithmVersion: value.algorithmVersion} + : {}), + id: value.id, + acquisition: { + cameraPosition, + torchUsed, + ...(typeof rawAcquisition?.lens === 'string' || rawAcquisition?.lens === null + ? {lens: rawAcquisition.lens as string | null} + : {}), + ...(typeof rawAcquisition?.deviceModel === 'string' || + rawAcquisition?.deviceModel === null + ? {deviceModel: rawAcquisition.deviceModel as string | null} + : {}), + }, + measuredAt: value.measuredAt, + atRest: value.atRest, + baselineEligible, + cameraResult: value.cameraResult, + potentialStress: value.potentialStress, + healthKitContext, + selfReport, + }; +} + +let mutationQueue: Promise = Promise.resolve(); + +const DEFAULT_DATA_POLICY: PPGDataPolicy = { + schemaVersion: 1, + retentionPolicy: 'forever', +}; + +const retentionDays: Record, number> = { + '30days': 30, + '90days': 90, + '1year': 365, +}; + +function retainMeasurements( + measurements: readonly StoredMeasurement[], + policy: DataRetentionPolicy, + now = Date.now(), +): StoredMeasurement[] { + if (policy === 'forever') return [...measurements]; + const cutoff = now - retentionDays[policy] * 86_400_000; + return measurements.filter(measurement => Date.parse(measurement.measuredAt) >= cutoff); +} + +async function readDataPolicy(): Promise { + const stored = await readJSON>( + storageKeys.dataPolicy, + DEFAULT_DATA_POLICY, + ); + const retentionPolicy = stored.retentionPolicy; + return { + schemaVersion: 1, + retentionPolicy: + retentionPolicy === '30days' || + retentionPolicy === '90days' || + retentionPolicy === '1year' || + retentionPolicy === 'forever' + ? retentionPolicy + : 'forever', + }; +} + +async function readAllFromStorage(): Promise { + const raw = await readJSON(storageKeys.measurements, []); + if (!Array.isArray(raw)) return []; + return raw + .map(parseStoredMeasurement) + .filter((measurement): measurement is StoredMeasurement => measurement !== null) + .sort((left, right) => Date.parse(right.measuredAt) - Date.parse(left.measuredAt)); +} + +function enqueueMutation(operation: () => Promise): Promise { + const pending = mutationQueue.then(operation); + mutationQueue = pending.catch(() => undefined); + return pending; +} + +export const measurementRepository = { + async getAll(): Promise { + await mutationQueue; + const [all, policy] = await Promise.all([readAllFromStorage(), readDataPolicy()]); + const retained = retainMeasurements(all, policy.retentionPolicy); + if (retained.length !== all.length) { + await writeJSON(storageKeys.measurements, retained); + } + return retained; + }, + + async getById(id: string): Promise { + return (await this.getAll()).find(measurement => measurement.id === id) ?? null; + }, + + async add(measurement: StoredMeasurement): Promise { + return enqueueMutation(async () => { + const current = await readAllFromStorage(); + const withoutDuplicate = current.filter(item => item.id !== measurement.id); + const policy = await readDataPolicy(); + await writeJSON( + storageKeys.measurements, + retainMeasurements([measurement, ...withoutDuplicate], policy.retentionPolicy), + ); + }); + }, + + async attachSelfReport( + measurementId: string, + selfReport: MeasurementSelfReport, + ): Promise { + return enqueueMutation(async () => { + const current = await readAllFromStorage(); + await writeJSON( + storageKeys.measurements, + current.map(measurement => + measurement.id === measurementId + ? { + ...measurement, + selfReport, + baselineEligible: + measurement.baselineEligible && + measurement.atRest && + reportAllowsBaseline(selfReport), + } + : measurement, + ), + ); + }); + }, + + async deleteById(measurementId: string): Promise { + return enqueueMutation(async () => { + const current = await readAllFromStorage(); + await writeJSON( + storageKeys.measurements, + current.filter(measurement => measurement.id !== measurementId), + ); + }); + }, + + async clearAll(): Promise { + return enqueueMutation(async () => { + await writeJSON(storageKeys.measurements, []); + }); + }, + + async getRetentionPolicy(): Promise { + await mutationQueue; + return (await readDataPolicy()).retentionPolicy; + }, + + async setRetentionPolicy(policy: DataRetentionPolicy): Promise { + return enqueueMutation(async () => { + await writeJSON(storageKeys.dataPolicy, { + schemaVersion: 1, + retentionPolicy: policy, + }); + const current = await readAllFromStorage(); + await writeJSON(storageKeys.measurements, retainMeasurements(current, policy)); + }); + }, +}; + +export function exportMeasurementsAsJSON( + measurements: readonly StoredMeasurement[], +): string { + return JSON.stringify( + { + format: 'haptika-ppg-export', + version: 1, + exportedAt: new Date().toISOString(), + measurements, + }, + null, + 2, + ); +} + +const csvCell = (value: string | number | boolean | null | undefined): string => { + const normalized = value === null || value === undefined ? '' : String(value); + return `"${normalized.replace(/"/g, '""')}"`; +}; + +export function exportMeasurementsAsCSV( + measurements: readonly StoredMeasurement[], +): string { + const rows = measurements.map(measurement => [ + measurement.id, + measurement.measuredAt, + measurement.acquisition.cameraPosition, + measurement.cameraResult.bpm.bpm, + measurement.cameraResult.prv.estimatedRmssdMs, + measurement.cameraResult.prv.estimatedSdnnMs, + measurement.cameraResult.quality.score, + measurement.potentialStress.index, + measurement.selfReport?.feeling, + measurement.baselineEligible, + ]); + return [ + ['id', 'measured_at', 'camera', 'bpm', 'rmssd_ms', 'sdnn_ms', 'quality', 'index', 'feeling', 'baseline_eligible'], + ...rows, + ] + .map(row => row.map(csvCell).join(',')) + .join('\n'); +} + +export function toBaselineMeasurement( + measurement: StoredMeasurement, +): BaselineMeasurement { + const measuredDate = new Date(measurement.measuredAt); + return { + id: measurement.id, + source: 'cameraPPG', + valid: measurement.cameraResult.valid && measurement.baselineEligible, + measuredAt: measurement.measuredAt, + bpm: measurement.cameraResult.bpm.bpm, + estimatedRmssdMs: measurement.cameraResult.prv.estimatedRmssdMs, + prvReliable: measurement.cameraResult.prv.reliable, + validIntervalCount: measurement.cameraResult.prv.validIntervalCount, + signalQuality: measurement.cameraResult.quality.score, + atRest: measurement.atRest, + measurementHourLocal: Number.isFinite(measuredDate.getTime()) + ? measuredDate.getHours() + : null, + cameraPosition: measurement.acquisition.cameraPosition, + }; +} diff --git a/src/features/ppg/storage/settingsRepository.ts b/src/features/ppg/storage/settingsRepository.ts new file mode 100644 index 0000000..f0dcdcb --- /dev/null +++ b/src/features/ppg/storage/settingsRepository.ts @@ -0,0 +1,27 @@ +import {readJSON, storageKeys, writeJSON} from './database'; +import type {PPGSettings} from './storageTypes'; + +const DEFAULT_SETTINGS: PPGSettings = { + schemaVersion: 1, + includeHealthKitContext: false, +}; + +export const settingsRepository = { + async get(): Promise { + const stored = await readJSON>( + storageKeys.settings, + DEFAULT_SETTINGS, + ); + return { + ...DEFAULT_SETTINGS, + includeHealthKitContext: stored.includeHealthKitContext === true, + }; + }, + + async setIncludeHealthKitContext(value: boolean): Promise { + await writeJSON(storageKeys.settings, { + schemaVersion: 1, + includeHealthKitContext: value, + }); + }, +}; diff --git a/src/features/ppg/storage/storageTypes.ts b/src/features/ppg/storage/storageTypes.ts new file mode 100644 index 0000000..75fdee0 --- /dev/null +++ b/src/features/ppg/storage/storageTypes.ts @@ -0,0 +1,34 @@ +import type {HealthKitContext} from '../health/healthTypes'; +import type {PPGProcessingResult} from '../measurement/measurementTypes'; +import type {PotentialStressResult} from '../stress/stressTypes'; + +export type FeelingScore = 1 | 2 | 3 | 4 | 5; + +export interface MeasurementSelfReport { + feeling: FeelingScore; + posture: 'seated' | 'lying' | null; + recentExercise: boolean | null; + recentCaffeine: boolean | null; + recentMeal: boolean | null; + movementDuringMeasurement: boolean | null; + phoneUnusuallyWarm: boolean | null; + recordedAt: string; +} + +export interface StoredMeasurement { + schemaVersion: 1; + id: string; + measuredAt: string; + atRest: boolean; + /** Eligibility for future baselines; never changes the already displayed result. */ + baselineEligible: boolean; + cameraResult: PPGProcessingResult; + potentialStress: PotentialStressResult; + healthKitContext: HealthKitContext | null; + selfReport: MeasurementSelfReport | null; +} + +export interface PPGSettings { + schemaVersion: 1; + includeHealthKitContext: boolean; +} diff --git a/src/features/ppg/stress/baselineCalculator.ts b/src/features/ppg/stress/baselineCalculator.ts new file mode 100644 index 0000000..6250db3 --- /dev/null +++ b/src/features/ppg/stress/baselineCalculator.ts @@ -0,0 +1,248 @@ +import type { + BaselineCalculationResult, + BaselineConfiguration, + BaselineContext, + BaselineMeasurement, + BaselineRejectionReason, + BaselineStatus, + PersonalBaseline, +} from './baselineTypes'; + +const circularHourDistance = (left: number, right: number): number => { + const distance = Math.abs(left - right); + return Math.min(distance, 24 - distance); +}; + +const optionalContextMatches = ( + measured: T | null | undefined, + target: T | null | undefined, +): boolean => target === null || target === undefined || measured === target; + +export const isContextuallyComparable = ( + measurement: BaselineMeasurement, + target: BaselineContext, + maximumHourDistance = 4, +): boolean => { + if ( + target.measurementHourLocal !== null && + validMeasurementHour(target.measurementHourLocal) && + (!validMeasurementHour(measurement.measurementHourLocal) || + circularHourDistance( + measurement.measurementHourLocal, + target.measurementHourLocal, + ) > maximumHourDistance) + ) { + return false; + } + return ( + optionalContextMatches(measurement.cameraPosition, target.cameraPosition) && + optionalContextMatches(measurement.posture, target.posture) && + optionalContextMatches(measurement.recentExercise, target.recentExercise) && + optionalContextMatches(measurement.recentCaffeine, target.recentCaffeine) && + optionalContextMatches(measurement.recentMeal, target.recentMeal) + ); +}; + +/** + * Uses a contextual subset once it is sufficiently representative, otherwise + * falls back to the complete resting baseline so early users are not blocked. + */ +export const calculateContextualBaseline = ( + measurements: readonly BaselineMeasurement[], + target: BaselineContext, + configuration: BaselineConfiguration = DEFAULT_BASELINE_CONFIGURATION, +): BaselineCalculationResult => { + const contextual = measurements.filter(measurement => + isContextuallyComparable(measurement, target), + ); + const minimumContextualCount = Math.min( + 3, + configuration.minMeasurementsForProvisional, + ); + return calculateBaseline( + contextual.length >= minimumContextualCount ? contextual : measurements, + configuration, + ); +}; + +export const DEFAULT_BASELINE_CONFIGURATION: Readonly = + Object.freeze({ + minimumSignalQuality: 0.65, + minimumBpm: 30, + maximumBpm: 240, + minimumReliableIntervals: 20, + minimumPrvMeasurements: 3, + minMeasurementsForProvisional: 5, + minMeasurementsForPersonalized: 10, + }); + +const isFiniteNumber = (value: number | null): value is number => + value !== null && Number.isFinite(value); + +export const median = (values: readonly number[]): number | null => { + const finiteValues = values.filter(Number.isFinite).sort((a, b) => a - b); + if (finiteValues.length === 0) { + return null; + } + + const middle = Math.floor(finiteValues.length / 2); + if (finiteValues.length % 2 === 0) { + const left = finiteValues[middle - 1]; + const right = finiteValues[middle]; + return left === undefined || right === undefined ? null : (left + right) / 2; + } + return finiteValues[middle] ?? null; +}; + +export const medianAbsoluteDeviation = ( + values: readonly number[], + center: number, +): number | null => median(values.map(value => Math.abs(value - center))); + +const rejectionReason = ( + measurement: BaselineMeasurement, + configuration: BaselineConfiguration, +): BaselineRejectionReason | null => { + if (measurement.source !== 'cameraPPG') { + return 'nonCameraSource'; + } + if (!measurement.valid) { + return 'invalidMeasurement'; + } + if (!measurement.atRest) { + return 'notAtRest'; + } + if (!Number.isFinite(Date.parse(measurement.measuredAt))) { + return 'invalidDate'; + } + if ( + !isFiniteNumber(measurement.bpm) || + measurement.bpm < configuration.minimumBpm || + measurement.bpm > configuration.maximumBpm + ) { + return 'invalidBpm'; + } + if ( + !Number.isFinite(measurement.signalQuality) || + measurement.signalQuality < configuration.minimumSignalQuality || + measurement.signalQuality > 1 + ) { + return 'insufficientSignalQuality'; + } + return null; +}; + +const validMeasurementHour = (hour: number | null): hour is number => + hour !== null && Number.isFinite(hour) && hour >= 0 && hour < 24; + +const isReliablePrvMeasurement = ( + measurement: BaselineMeasurement, + configuration: BaselineConfiguration, +): boolean => + measurement.prvReliable && + measurement.validIntervalCount >= configuration.minimumReliableIntervals && + isFiniteNumber(measurement.estimatedRmssdMs) && + measurement.estimatedRmssdMs > 0; + +export const calculateBaseline = ( + measurements: readonly BaselineMeasurement[], + configuration: BaselineConfiguration = DEFAULT_BASELINE_CONFIGURATION, +): BaselineCalculationResult => { + const accepted: BaselineMeasurement[] = []; + const rejected: BaselineCalculationResult['rejectedMeasurements'][number][] = + []; + const seenIds = new Set(); + + for (const measurement of measurements) { + if (seenIds.has(measurement.id)) { + rejected.push({ + id: measurement.id, + reason: 'duplicateMeasurement', + }); + continue; + } + seenIds.add(measurement.id); + + const reason = rejectionReason(measurement, configuration); + if (reason !== null) { + rejected.push({id: measurement.id, reason}); + continue; + } + accepted.push(measurement); + } + + if (accepted.length === 0) { + return { + baseline: null, + acceptedMeasurementIds: [], + rejectedMeasurements: rejected, + }; + } + + const bpmValues = accepted.map(measurement => measurement.bpm as number); + const medianBpm = median(bpmValues) as number; + const bpmMad = medianAbsoluteDeviation(bpmValues, medianBpm) as number; + const medianSignalQuality = median( + accepted.map(measurement => measurement.signalQuality), + ) as number; + + const logRmssdValues = accepted + .filter(measurement => + isReliablePrvMeasurement(measurement, configuration), + ) + .map(measurement => Math.log(measurement.estimatedRmssdMs as number)); + const medianLogRmssd = + logRmssdValues.length >= configuration.minimumPrvMeasurements + ? median(logRmssdValues) + : null; + const logRmssdMad = + medianLogRmssd === null + ? null + : medianAbsoluteDeviation(logRmssdValues, medianLogRmssd); + + const measurementHours = accepted + .map(measurement => measurement.measurementHourLocal) + .filter(validMeasurementHour); + const updatedAtMilliseconds = Math.max( + ...accepted.map(measurement => Date.parse(measurement.measuredAt)), + ); + + return { + baseline: { + validMeasurementCount: accepted.length, + medianBpm, + bpmMad, + medianLogRmssd, + logRmssdMad, + medianSignalQuality, + measurementHourMedian: median(measurementHours), + updatedAt: new Date(updatedAtMilliseconds).toISOString(), + }, + acceptedMeasurementIds: accepted.map(measurement => measurement.id), + rejectedMeasurements: rejected, + }; +}; + +export const calculatePersonalBaseline = ( + measurements: readonly BaselineMeasurement[], + configuration: BaselineConfiguration = DEFAULT_BASELINE_CONFIGURATION, +): PersonalBaseline | null => + calculateBaseline(measurements, configuration).baseline; + +export const getBaselineStatus = ( + validMeasurementCount: number, + configuration: Pick< + BaselineConfiguration, + 'minMeasurementsForProvisional' | 'minMeasurementsForPersonalized' + > = DEFAULT_BASELINE_CONFIGURATION, +): BaselineStatus => { + if ( + validMeasurementCount >= configuration.minMeasurementsForPersonalized + ) { + return 'personalized'; + } + if (validMeasurementCount >= configuration.minMeasurementsForProvisional) { + return 'provisional'; + } + return 'building'; +}; diff --git a/src/features/ppg/stress/baselineTypes.ts b/src/features/ppg/stress/baselineTypes.ts new file mode 100644 index 0000000..038f125 --- /dev/null +++ b/src/features/ppg/stress/baselineTypes.ts @@ -0,0 +1,64 @@ +export type BaselineMeasurementSource = + | 'cameraPPG' + | 'healthKit' + | 'manualFixture'; + +export interface BaselineMeasurement { + id: string; + source: BaselineMeasurementSource; + valid: boolean; + measuredAt: string; + bpm: number | null; + estimatedRmssdMs: number | null; + prvReliable: boolean; + validIntervalCount: number; + signalQuality: number; + atRest: boolean; + measurementHourLocal: number | null; +} + +export interface PersonalBaseline { + validMeasurementCount: number; + medianBpm: number; + bpmMad: number; + medianLogRmssd: number | null; + logRmssdMad: number | null; + medianSignalQuality: number; + measurementHourMedian: number | null; + updatedAt: string; +} + +export interface BaselineConfiguration { + minimumSignalQuality: number; + minimumBpm: number; + maximumBpm: number; + minimumReliableIntervals: number; + minimumPrvMeasurements: number; + minMeasurementsForProvisional: number; + minMeasurementsForPersonalized: number; +} + +export type BaselineStatus = + | 'building' + | 'provisional' + | 'personalized'; + +export type BaselineRejectionReason = + | 'duplicateMeasurement' + | 'nonCameraSource' + | 'invalidMeasurement' + | 'notAtRest' + | 'invalidDate' + | 'invalidBpm' + | 'insufficientSignalQuality'; + +export interface RejectedBaselineMeasurement { + id: string; + reason: BaselineRejectionReason; +} + +export interface BaselineCalculationResult { + baseline: PersonalBaseline | null; + acceptedMeasurementIds: readonly string[]; + rejectedMeasurements: readonly RejectedBaselineMeasurement[]; +} diff --git a/src/features/ppg/stress/confidenceCalculator.ts b/src/features/ppg/stress/confidenceCalculator.ts new file mode 100644 index 0000000..467ce3f --- /dev/null +++ b/src/features/ppg/stress/confidenceCalculator.ts @@ -0,0 +1,116 @@ +import type {StressIndexConfiguration} from './stressIndexConfiguration'; +import {DEFAULT_STRESS_INDEX_CONFIGURATION} from './stressIndexConfiguration'; + +export interface ConfidenceConfiguration { + signalQualityWeight: number; + baselineWeight: number; + peakWeight: number; + methodAgreementWeight: number; + contextConsistencyWeight: number; + baselineBuildingConfidenceCap: number; +} + +export const DEFAULT_CONFIDENCE_CONFIGURATION: Readonly = + Object.freeze({ + signalQualityWeight: 0.4, + baselineWeight: 0.2, + peakWeight: 0.2, + methodAgreementWeight: 0.15, + contextConsistencyWeight: 0.05, + baselineBuildingConfidenceCap: 0.49, + }); + +export interface ConfidenceInput { + signalQuality: number; + usableDurationSeconds: number; + targetDurationSeconds: number; + baselineMeasurementCount: number; + peakConfidence: number; + methodAgreementScore: number; + contextConsistency: number; + prvReliable: boolean; +} + +export interface ConfidenceBreakdown { + signalQuality: number; + duration: number; + baseline: number; + peaks: number; + methodAgreement: number; + contextConsistency: number; +} + +export interface ConfidenceResult { + score: number; + breakdown: ConfidenceBreakdown; + modifiers: readonly ('missingReliablePrv' | 'baselineCap')[]; +} + +const clamp01 = (value: number): number => + Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : 0; + +export const calculateConfidence = ( + input: ConfidenceInput, + stressConfiguration: StressIndexConfiguration = + DEFAULT_STRESS_INDEX_CONFIGURATION, + confidenceConfiguration: ConfidenceConfiguration = + DEFAULT_CONFIDENCE_CONFIGURATION, +): ConfidenceResult => { + const duration = + Number.isFinite(input.targetDurationSeconds) && + input.targetDurationSeconds > 0 + ? clamp01(input.usableDurationSeconds / input.targetDurationSeconds) + : 0; + const signalQuality = clamp01(input.signalQuality); + const baseline = clamp01( + input.baselineMeasurementCount / + stressConfiguration.minMeasurementsForPersonalized, + ); + const peaks = clamp01(input.peakConfidence); + const methodAgreement = clamp01(input.methodAgreementScore); + const contextConsistency = clamp01(input.contextConsistency); + const effectiveSignalQuality = signalQuality * duration; + + let score = + confidenceConfiguration.signalQualityWeight * effectiveSignalQuality + + confidenceConfiguration.baselineWeight * baseline + + confidenceConfiguration.peakWeight * peaks + + confidenceConfiguration.methodAgreementWeight * methodAgreement + + confidenceConfiguration.contextConsistencyWeight * contextConsistency; + const modifiers: ConfidenceResult['modifiers'][number][] = []; + + if (!input.prvReliable) { + score *= stressConfiguration.unreliablePrvConfidenceMultiplier; + modifiers.push('missingReliablePrv'); + } + + if ( + input.baselineMeasurementCount < + stressConfiguration.minMeasurementsForProvisional + ) { + score = Math.min( + score, + confidenceConfiguration.baselineBuildingConfidenceCap, + ); + modifiers.push('baselineCap'); + } else if ( + input.baselineMeasurementCount < + stressConfiguration.minMeasurementsForPersonalized + ) { + score = Math.min(score, stressConfiguration.provisionalConfidenceCap); + modifiers.push('baselineCap'); + } + + return { + score: clamp01(score), + breakdown: { + signalQuality, + duration, + baseline, + peaks, + methodAgreement, + contextConsistency, + }, + modifiers, + }; +}; diff --git a/src/features/ppg/stress/potentialStressCalculator.ts b/src/features/ppg/stress/potentialStressCalculator.ts new file mode 100644 index 0000000..a005928 --- /dev/null +++ b/src/features/ppg/stress/potentialStressCalculator.ts @@ -0,0 +1,214 @@ +import {calculateConfidence} from './confidenceCalculator'; +import type { + PotentialStressInput, + PotentialStressResult, + PotentialStressStatus, + StressLimitation, +} from './stressTypes'; +import type {StressIndexConfiguration} from './stressIndexConfiguration'; +import {DEFAULT_STRESS_INDEX_CONFIGURATION} from './stressIndexConfiguration'; + +const ROBUST_MAD_SCALE = 1.4826; +const TARGET_USABLE_DURATION_SECONDS = 55; + +const clamp = (value: number, minimum: number, maximum: number): number => + Math.min(Math.max(value, minimum), maximum); + +const clamp01 = (value: number): number => + Number.isFinite(value) ? clamp(value, 0, 1) : 0; + +const sigmoid = (value: number): number => 1 / (1 + Math.exp(-value)); + +export const robustZScore = ( + value: number, + baselineMedian: number, + baselineMad: number, + configuration: Pick, +): number => { + if ( + !Number.isFinite(value) || + !Number.isFinite(baselineMedian) || + !Number.isFinite(baselineMad) || + baselineMad < 0 + ) { + return 0; + } + const denominator = ROBUST_MAD_SCALE * baselineMad + configuration.epsilon; + return clamp( + (value - baselineMedian) / denominator, + -configuration.zScoreLimit, + configuration.zScoreLimit, + ); +}; + +const statusForBaselineCount = ( + count: number, + configuration: StressIndexConfiguration, +): PotentialStressStatus => { + if (count < configuration.minMeasurementsForProvisional) { + return 'baselineBuilding'; + } + if (count < configuration.minMeasurementsForPersonalized) { + return 'provisional'; + } + return 'personalized'; +}; + +const buildEmptyResult = ( + input: PotentialStressInput, + status: Extract, + limitations: readonly StressLimitation[], +): PotentialStressResult => ({ + index: null, + confidence: 0, + status, + bpmContribution: null, + prvContribution: null, + signalQuality: clamp01(input.signalQuality), + baselineMeasurementCount: input.baseline?.validMeasurementCount ?? 0, + healthKitUsedForScore: false, + limitations, +}); + +export const calculatePotentialStress = ( + input: PotentialStressInput, + configuration: StressIndexConfiguration = + DEFAULT_STRESS_INDEX_CONFIGURATION, +): PotentialStressResult => { + const signalQuality = clamp01(input.signalQuality); + if ( + input.bpm === null || + !Number.isFinite(input.bpm) || + input.bpm <= 0 + ) { + return buildEmptyResult(input, 'unavailable', ['invalidBpm']); + } + if ( + !Number.isFinite(input.signalQuality) || + signalQuality < configuration.minSignalQuality + ) { + return buildEmptyResult(input, 'unavailable', [ + 'signalQualityTooLow', + ]); + } + if (input.atRest === false) { + return buildEmptyResult(input, 'unavailable', ['measurementNotAtRest']); + } + + const baseline = input.baseline; + if ( + baseline === null || + baseline.validMeasurementCount < + configuration.minMeasurementsForProvisional + ) { + return buildEmptyResult(input, 'baselineBuilding', ['baselineBuilding']); + } + if ( + !Number.isFinite(baseline.medianBpm) || + !Number.isFinite(baseline.bpmMad) || + baseline.bpmMad < 0 + ) { + return buildEmptyResult(input, 'unavailable', ['invalidBpm']); + } + + const limitations = new Set(); + const status = statusForBaselineCount( + baseline.validMeasurementCount, + configuration, + ); + if (status === 'provisional') { + limitations.add('provisionalBaseline'); + } + if (baseline.bpmMad <= configuration.epsilon) { + limitations.add('baselineBpmDispersionLow'); + } + if ( + !Number.isFinite(input.usableDurationSeconds) || + input.usableDurationSeconds < TARGET_USABLE_DURATION_SECONDS * 0.9 + ) { + limitations.add('usableDurationTooShort'); + } + + const zBpm = robustZScore( + input.bpm, + baseline.medianBpm, + baseline.bpmMad, + configuration, + ); + const bpmActivation = sigmoid(configuration.bpmSensitivity * zBpm); + + const currentPrvAvailable = + input.estimatedRmssdMs !== null && + Number.isFinite(input.estimatedRmssdMs) && + input.estimatedRmssdMs > 0; + const enoughIntervals = + input.validIntervalCount >= configuration.minReliableIntervals; + const baselinePrvAvailable = + baseline.medianLogRmssd !== null && + Number.isFinite(baseline.medianLogRmssd) && + baseline.logRmssdMad !== null && + Number.isFinite(baseline.logRmssdMad) && + baseline.logRmssdMad >= 0; + const usePrv = + input.prvReliable && + currentPrvAvailable && + enoughIntervals && + baselinePrvAvailable; + + if (!currentPrvAvailable) { + limitations.add('prvUnavailable'); + } else if (!input.prvReliable) { + limitations.add('prvUnreliable'); + } else if (!enoughIntervals) { + limitations.add('insufficientReliableIntervals'); + } + if (!baselinePrvAvailable) { + limitations.add('baselinePrvUnavailable'); + } + + let bpmContribution: number; + let prvContribution: number | null = null; + let rawActivation: number; + if (usePrv) { + const zPrv = robustZScore( + Math.log(input.estimatedRmssdMs as number), + baseline.medianLogRmssd as number, + baseline.logRmssdMad as number, + configuration, + ); + const prvActivation = sigmoid(-configuration.prvSensitivity * zPrv); + bpmContribution = configuration.bpmWeight * bpmActivation; + prvContribution = configuration.prvWeight * prvActivation; + rawActivation = bpmContribution + prvContribution; + } else { + bpmContribution = bpmActivation; + rawActivation = bpmActivation; + } + + const confidence = calculateConfidence( + { + signalQuality, + usableDurationSeconds: input.usableDurationSeconds, + targetDurationSeconds: TARGET_USABLE_DURATION_SECONDS, + baselineMeasurementCount: baseline.validMeasurementCount, + peakConfidence: input.peakConfidence, + methodAgreementScore: input.methodAgreementScore, + contextConsistency: input.contextConsistency ?? 0.5, + prvReliable: usePrv, + }, + configuration, + ).score; + + return { + index: Math.round(clamp01(rawActivation) * 100), + confidence, + status, + bpmContribution: clamp01(bpmContribution), + prvContribution: + prvContribution === null ? null : clamp01(prvContribution), + signalQuality, + baselineMeasurementCount: baseline.validMeasurementCount, + healthKitUsedForScore: false, + limitations: [...limitations], + }; +}; diff --git a/src/features/ppg/stress/stressIndexConfiguration.ts b/src/features/ppg/stress/stressIndexConfiguration.ts new file mode 100644 index 0000000..2248d64 --- /dev/null +++ b/src/features/ppg/stress/stressIndexConfiguration.ts @@ -0,0 +1,115 @@ +import {PPG_PUBLICATION_THRESHOLDS} from '../measurement/measurementConfiguration'; + +export interface StressIndexConfiguration { + bpmWeight: number; + prvWeight: number; + healthKitWeight: number; + bpmSensitivity: number; + prvSensitivity: number; + minMeasurementsForProvisional: number; + minMeasurementsForPersonalized: number; + minSignalQuality: number; + minReliableIntervals: number; + epsilon: number; + zScoreLimit: number; + provisionalConfidenceCap: number; + unreliablePrvConfidenceMultiplier: number; +} + +export const DEFAULT_STRESS_INDEX_CONFIGURATION: Readonly = + Object.freeze({ + bpmWeight: 0.6, + prvWeight: 0.4, + healthKitWeight: 0, + bpmSensitivity: 1.2, + prvSensitivity: 1.2, + minMeasurementsForProvisional: 5, + minMeasurementsForPersonalized: 10, + minSignalQuality: PPG_PUBLICATION_THRESHOLDS.potentialIndexQuality, + minReliableIntervals: + PPG_PUBLICATION_THRESHOLDS.potentialIndexMinimumIntervals, + epsilon: 1e-6, + zScoreLimit: 3, + provisionalConfidenceCap: 0.69, + unreliablePrvConfidenceMultiplier: 0.6, + }); + +const assertFinite = (name: string, value: number): void => { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be finite.`); + } +}; + +const assertRatio = (name: string, value: number): void => { + assertFinite(name, value); + if (value < 0 || value > 1) { + throw new RangeError(`${name} must be between 0 and 1.`); + } +}; + +export const validateStressIndexConfiguration = ( + configuration: StressIndexConfiguration, +): StressIndexConfiguration => { + assertRatio('bpmWeight', configuration.bpmWeight); + assertRatio('prvWeight', configuration.prvWeight); + assertRatio('healthKitWeight', configuration.healthKitWeight); + if (configuration.healthKitWeight !== 0) { + throw new RangeError( + 'healthKitWeight must remain 0 until explicit scientific and product validation.', + ); + } + if (Math.abs(configuration.bpmWeight + configuration.prvWeight - 1) > 1e-9) { + throw new RangeError('bpmWeight and prvWeight must sum to 1.'); + } + + assertFinite('bpmSensitivity', configuration.bpmSensitivity); + assertFinite('prvSensitivity', configuration.prvSensitivity); + if (configuration.bpmSensitivity <= 0 || configuration.prvSensitivity <= 0) { + throw new RangeError('Stress sensitivities must be positive.'); + } + + if ( + !Number.isInteger(configuration.minMeasurementsForProvisional) || + !Number.isInteger(configuration.minMeasurementsForPersonalized) || + configuration.minMeasurementsForProvisional < 1 || + configuration.minMeasurementsForPersonalized < + configuration.minMeasurementsForProvisional + ) { + throw new RangeError('Baseline measurement thresholds are invalid.'); + } + + assertRatio('minSignalQuality', configuration.minSignalQuality); + if ( + !Number.isInteger(configuration.minReliableIntervals) || + configuration.minReliableIntervals < 3 + ) { + throw new RangeError('minReliableIntervals must be at least 3.'); + } + + assertFinite('epsilon', configuration.epsilon); + assertFinite('zScoreLimit', configuration.zScoreLimit); + if (configuration.epsilon <= 0 || configuration.zScoreLimit <= 0) { + throw new RangeError('epsilon and zScoreLimit must be positive.'); + } + + assertRatio( + 'provisionalConfidenceCap', + configuration.provisionalConfidenceCap, + ); + assertRatio( + 'unreliablePrvConfidenceMultiplier', + configuration.unreliablePrvConfidenceMultiplier, + ); + return configuration; +}; + +export const createStressIndexConfiguration = ( + overrides: Partial = {}, +): Readonly => { + const configuration: StressIndexConfiguration = { + ...DEFAULT_STRESS_INDEX_CONFIGURATION, + ...overrides, + }; + validateStressIndexConfiguration(configuration); + return Object.freeze(configuration); +}; diff --git a/src/features/ppg/stress/stressInterpretation.ts b/src/features/ppg/stress/stressInterpretation.ts new file mode 100644 index 0000000..67bed77 --- /dev/null +++ b/src/features/ppg/stress/stressInterpretation.ts @@ -0,0 +1,40 @@ +import type { + PotentialStressInterpretation, + PotentialStressInterpretationLevel, +} from './stressTypes'; + +export const POTENTIAL_STRESS_DISCLAIMER = + 'Cette estimation dépend de la qualité du signal et de vos données habituelles. Elle ne constitue pas une mesure médicale du stress.'; + +const unavailable = (): PotentialStressInterpretation => ({ + level: 'unavailable', + message: 'Estimation indisponible', + disclaimer: POTENTIAL_STRESS_DISCLAIMER, +}); + +export const interpretPotentialStressIndex = ( + index: number | null, +): PotentialStressInterpretation => { + if (index === null || !Number.isFinite(index) || index < 0 || index > 100) { + return unavailable(); + } + + let level: PotentialStressInterpretationLevel; + let message: string; + if (index <= 30) { + level = 'low'; + message = + 'Peu de signes d’activation par rapport à votre niveau habituel'; + } else if (index <= 60) { + level = 'moderate'; + message = 'Activation potentielle modérée'; + } else if (index <= 80) { + level = 'high'; + message = 'Activation potentielle élevée'; + } else { + level = 'veryHigh'; + message = 'Activation potentielle très élevée'; + } + + return {level, message, disclaimer: POTENTIAL_STRESS_DISCLAIMER}; +}; diff --git a/src/features/ppg/stress/stressTypes.ts b/src/features/ppg/stress/stressTypes.ts new file mode 100644 index 0000000..9171053 --- /dev/null +++ b/src/features/ppg/stress/stressTypes.ts @@ -0,0 +1,61 @@ +import type {PersonalBaseline} from './baselineTypes'; + +export type PotentialStressStatus = + | 'unavailable' + | 'baselineBuilding' + | 'provisional' + | 'personalized'; + +export type StressLimitation = + | 'invalidBpm' + | 'measurementNotAtRest' + | 'signalQualityTooLow' + | 'baselineBuilding' + | 'provisionalBaseline' + | 'baselineBpmDispersionLow' + | 'prvUnavailable' + | 'prvUnreliable' + | 'insufficientReliableIntervals' + | 'baselinePrvUnavailable' + | 'usableDurationTooShort'; + +export interface PotentialStressInput { + atRest?: boolean; + bpm: number | null; + estimatedRmssdMs: number | null; + prvReliable: boolean; + validIntervalCount: number; + signalQuality: number; + usableDurationSeconds: number; + peakConfidence: number; + methodAgreementScore: number; + contextConsistency?: number; + baseline: PersonalBaseline | null; +} + +export interface PotentialStressResult { + index: number | null; + confidence: number; + status: PotentialStressStatus; + /** Weighted normalized contribution in [0, 1]. */ + bpmContribution: number | null; + /** Weighted normalized contribution in [0, 1], or null when PRV is omitted. */ + prvContribution: number | null; + signalQuality: number; + baselineMeasurementCount: number; + healthKitUsedForScore: false; + limitations: readonly StressLimitation[]; +} + +export type PotentialStressInterpretationLevel = + | 'unavailable' + | 'low' + | 'moderate' + | 'high' + | 'veryHigh'; + +export interface PotentialStressInterpretation { + level: PotentialStressInterpretationLevel; + message: string; + disclaimer: string; +} diff --git a/src/features/ppg/tests/PPGCameraView.test.tsx b/src/features/ppg/tests/PPGCameraView.test.tsx new file mode 100644 index 0000000..6c64b72 --- /dev/null +++ b/src/features/ppg/tests/PPGCameraView.test.tsx @@ -0,0 +1,352 @@ +import React, {useState} from 'react'; +import ReactTestRenderer from 'react-test-renderer'; +import {haptikaPPG} from 'react-native-haptika-ppg'; + +import {PPGCameraView} from '../camera/PPGCameraView'; + +jest.mock('react-native-worklets', () => ({ + scheduleOnRN: ( + callback: (...args: readonly unknown[]) => void, + ...args: readonly unknown[] + ): void => callback(...args), +})); + +jest.mock('react-native-vision-camera', () => { + const ReactModule = require('react') as typeof React; + let frameOutputOptions: { + onFrame: (frame: {dispose: () => void}) => void; + }; + let cameraProps: + | { + enableLowLightBoost?: boolean; + enableDistortionCorrection?: boolean; + } + | undefined; + const controller = { + device: { + hasTorch: true, + supportsTorchStrength: true, + minTorchStrength: 0.2, + maxTorchStrength: 1, + supportsExposureBias: true, + minExposureBias: -3, + maxExposureBias: 3, + supportsExposureLocking: true, + supportsWhiteBalanceLocking: true, + supportsFocusLocking: true, + position: 'back', + type: 'wide-angle', + isVirtualDevice: false, + }, + torchMode: 'off', + torchStrength: 1, + exposureBias: 0, + enableTorchWithStrength: jest.fn(async () => undefined), + setTorchMode: jest.fn(async () => undefined), + setExposureBias: jest.fn(async () => undefined), + lockCurrentExposure: jest.fn(async () => undefined), + lockCurrentWhiteBalance: jest.fn(async () => undefined), + lockCurrentFocus: jest.fn(async () => undefined), + }; + let hookDevices: Array = [controller.device]; + + return { + Camera: ReactModule.forwardRef( + ( + props: { + device: unknown; + onStarted: () => void; + enableLowLightBoost?: boolean; + enableDistortionCorrection?: boolean; + }, + ref: React.ForwardedRef<{controller: typeof controller}>, + ) => { + cameraProps = props; + ReactModule.useImperativeHandle(ref, () => ({controller})); + ReactModule.useEffect(() => props.onStarted(), [props]); + return null; + }, + ), + useCameraDevices: () => hookDevices, + useFrameOutput: (options: typeof frameOutputOptions) => { + frameOutputOptions = options; + return {}; + }, + __controller: controller, + __getCameraProps: () => cameraProps, + __getFrameOutputOptions: () => frameOutputOptions, + __setHookDevices: (devices: Array) => { + hookDevices = devices; + }, + }; +}); + +jest.mock('react-native-haptika-ppg', () => ({ + haptikaPPG: { + processFrame: jest.fn(), + }, +})); + +interface VisionCameraMock { + __controller: { + device: unknown; + enableTorchWithStrength: jest.Mock; + setTorchMode: jest.Mock; + setExposureBias: jest.Mock; + lockCurrentExposure: jest.Mock; + lockCurrentWhiteBalance: jest.Mock; + lockCurrentFocus: jest.Mock; + }; + __getCameraProps: () => + | { + device?: unknown; + enableLowLightBoost?: boolean; + enableDistortionCorrection?: boolean; + } + | undefined; + __getFrameOutputOptions: () => { + onFrame: (frame: {dispose: () => void}) => void; + }; + __setHookDevices: (devices: unknown[]) => void; +} + +const visionCamera = jest.requireMock( + 'react-native-vision-camera', +) as VisionCameraMock; +const nativePPG = haptikaPPG as jest.Mocked; + +describe('PPGCameraView', () => { + beforeEach(() => { + jest.clearAllMocks(); + visionCamera.__setHookDevices([visionCamera.__controller.device]); + }); + + test('keeps the standard torch enabled after camera readiness', async () => { + function Harness(): React.JSX.Element { + const [torchEnabled, setTorchEnabled] = useState(false); + return ( + setTorchEnabled(true)} + onFrameUpdate={jest.fn()} + onInterrupted={jest.fn()} + onError={jest.fn()} + /> + ); + } + + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create(); + }); + + expect( + visionCamera.__controller.enableTorchWithStrength, + ).toHaveBeenCalledWith(0.2); + expect(visionCamera.__controller.setExposureBias).toHaveBeenCalledWith(-1); + + ReactTestRenderer.act(() => renderer.unmount()); + }); + + test('prefers the physical 0.5x camera when it has a torch', async () => { + const wideDevice = visionCamera.__controller.device as Record< + string, + unknown + >; + const ultraWideDevice = { + ...wideDevice, + type: 'ultra-wide-angle', + id: 'physical-ultra-wide', + }; + visionCamera.__setHookDevices([ + visionCamera.__controller.device, + ultraWideDevice, + ]); + + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create( + , + ); + }); + + expect( + (visionCamera.__getCameraProps()?.device as {type: string}).type, + ).toBe('ultra-wide-angle'); + ReactTestRenderer.act(() => renderer.unmount()); + }); + + test('selects the front camera without requiring a torch', async () => { + const frontDevice = { + ...(visionCamera.__controller.device as Record), + id: 'front-wide', + position: 'front', + hasTorch: false, + }; + visionCamera.__setHookDevices([ + visionCamera.__controller.device, + frontDevice, + ]); + + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create( + , + ); + }); + + expect( + (visionCamera.__getCameraProps()?.device as {position: string}).position, + ).toBe('front'); + expect( + visionCamera.__controller.enableTorchWithStrength, + ).not.toHaveBeenCalled(); + ReactTestRenderer.act(() => renderer.unmount()); + }); + + test('does not configure unsupported optional camera features', async () => { + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create( + , + ); + }); + + expect(visionCamera.__getCameraProps()).not.toHaveProperty( + 'enableLowLightBoost', + ); + expect(visionCamera.__getCameraProps()).not.toHaveProperty( + 'enableDistortionCorrection', + ); + + ReactTestRenderer.act(() => renderer.unmount()); + }); + + test('skips isolated frame errors and reports a sustained processor failure', async () => { + const onError = jest.fn(); + const dispose = jest.fn(); + nativePPG.processFrame.mockImplementation(() => { + throw new Error('Native frame failure'); + }); + + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create( + , + ); + }); + + for (let index = 0; index < 9; index += 1) { + visionCamera.__getFrameOutputOptions().onFrame({dispose}); + } + expect(onError).not.toHaveBeenCalled(); + visionCamera.__getFrameOutputOptions().onFrame({dispose}); + + expect(dispose).toHaveBeenCalledTimes(10); + expect(onError).toHaveBeenCalledWith({ + code: 'cameraRuntime', + message: 'Native frame failure', + }); + expect(visionCamera.__controller.setTorchMode).toHaveBeenCalledWith('off'); + + ReactTestRenderer.act(() => renderer.unmount()); + }); + + test('reports unavailable when the physical 1x camera is missing', async () => { + jest.useFakeTimers(); + const onError = jest.fn(); + visionCamera.__setHookDevices([]); + + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create( + , + ); + }); + + await ReactTestRenderer.act(async () => { + jest.advanceTimersByTime(2_000); + }); + expect(onError).toHaveBeenCalledWith({ + code: 'cameraUnavailable', + message: 'Aucune caméra arrière avec flash n’est disponible.', + }); + + ReactTestRenderer.act(() => renderer.unmount()); + jest.useRealTimers(); + }); + + test('locks optical controls after contact stabilizes', async () => { + jest.useFakeTimers(); + let renderer: ReactTestRenderer.ReactTestRenderer; + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create( + , + ); + }); + + await ReactTestRenderer.act(async () => { + jest.advanceTimersByTime(1_000); + }); + + expect(visionCamera.__controller.lockCurrentExposure).toHaveBeenCalled(); + expect( + visionCamera.__controller.lockCurrentWhiteBalance, + ).toHaveBeenCalled(); + expect(visionCamera.__controller.lockCurrentFocus).toHaveBeenCalled(); + + ReactTestRenderer.act(() => renderer.unmount()); + jest.useRealTimers(); + }); +}); diff --git a/src/features/ppg/tests/acquisitionPreferencesRepository.test.ts b/src/features/ppg/tests/acquisitionPreferencesRepository.test.ts new file mode 100644 index 0000000..ee17e6b --- /dev/null +++ b/src/features/ppg/tests/acquisitionPreferencesRepository.test.ts @@ -0,0 +1,43 @@ +const mockStore = new Map(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { + mockStore.set(key, value); + }), + }, +})); + +import {acquisitionPreferencesRepository} from '../storage/acquisitionPreferencesRepository'; + +describe('acquisition preferences', () => { + beforeEach(() => mockStore.clear()); + + test('uses safe defaults and persists camera/contact choices', async () => { + await expect(acquisitionPreferencesRepository.get()).resolves.toEqual({ + cameraPosition: 'front', + sensitiveContact: true, + }); + await acquisitionPreferencesRepository.set({ + cameraPosition: 'back', + sensitiveContact: false, + }); + await expect(acquisitionPreferencesRepository.get()).resolves.toEqual({ + cameraPosition: 'back', + sensitiveContact: false, + }); + }); + + test('repairs malformed persisted values', async () => { + mockStore.set( + '@haptika/ppg/v1/acquisition-preferences', + JSON.stringify({cameraPosition: 'side', sensitiveContact: 'yes'}), + ); + await expect(acquisitionPreferencesRepository.get()).resolves.toEqual({ + cameraPosition: 'front', + sensitiveContact: true, + }); + }); +}); diff --git a/src/features/ppg/tests/baselineAndStress.test.ts b/src/features/ppg/tests/baselineAndStress.test.ts new file mode 100644 index 0000000..9af7699 --- /dev/null +++ b/src/features/ppg/tests/baselineAndStress.test.ts @@ -0,0 +1,190 @@ +import { + calculateBaseline, + calculateContextualBaseline, + calculatePersonalBaseline, + isContextuallyComparable, +} from '../stress/baselineCalculator'; +import {calculatePotentialStress} from '../stress/potentialStressCalculator'; +import {createStressIndexConfiguration} from '../stress/stressIndexConfiguration'; +import {interpretPotentialStressIndex} from '../stress/stressInterpretation'; +import { + makeBaselineMeasurement, + makeBaselineMeasurements, +} from './syntheticMeasurements'; + +describe('personal baseline', () => { + test('compares local time circularly and respects recorded context', () => { + const measurement = { + ...makeBaselineMeasurements(1)[0]!, + measurementHourLocal: 23, + posture: 'seated' as const, + recentCaffeine: false, + recentMeal: false, + recentExercise: false, + }; + expect( + isContextuallyComparable(measurement, { + measurementHourLocal: 1, + posture: 'seated', + recentCaffeine: false, + }), + ).toBe(true); + expect( + isContextuallyComparable(measurement, {measurementHourLocal: 12}), + ).toBe(false); + expect( + isContextuallyComparable(measurement, { + measurementHourLocal: 1, + posture: 'lying', + }), + ).toBe(false); + }); + + test('uses a sufficiently populated contextual subset', () => { + const measurements = makeBaselineMeasurements(6).map((measurement, index) => ({ + ...measurement, + measurementHourLocal: index < 3 ? 8 : 20, + bpm: index < 3 ? 60 : 90, + })); + const result = calculateContextualBaseline(measurements, { + measurementHourLocal: 8, + }); + expect(result.baseline?.validMeasurementCount).toBe(3); + expect(result.baseline?.medianBpm).toBe(60); + }); + + test('uses camera-only valid resting measurements and robust statistics', () => { + const measurements = [ + ...makeBaselineMeasurements(10), + makeBaselineMeasurement(10, {id: 'outlier', bpm: 180}), + makeBaselineMeasurement(11, { + id: 'health-context', + source: 'healthKit', + }), + makeBaselineMeasurement(12, {id: 'moving', atRest: false}), + makeBaselineMeasurement(13, {id: 'poor', signalQuality: 0.4}), + makeBaselineMeasurement(0), + ]; + const result = calculateBaseline(measurements); + + expect(result.baseline?.validMeasurementCount).toBe(11); + expect(result.baseline?.medianBpm).toBe(60); + expect(result.baseline?.bpmMad).toBeLessThanOrEqual(1); + expect(result.rejectedMeasurements.map(item => item.reason)).toEqual( + expect.arrayContaining([ + 'nonCameraSource', + 'notAtRest', + 'insufficientSignalQuality', + 'duplicateMeasurement', + ]), + ); + }); +}); + +describe('potential stress index', () => { + const commonInput = { + bpm: 75, + estimatedRmssdMs: 20, + prvReliable: true, + validIntervalCount: 50, + signalQuality: 0.95, + usableDurationSeconds: 55, + peakConfidence: 0.9, + methodAgreementScore: 0.95, + contextConsistency: 0.9, + }; + + test('does not produce a personalized score before five measurements', () => { + const baseline = calculatePersonalBaseline(makeBaselineMeasurements(4)); + const result = calculatePotentialStress({...commonInput, baseline}); + + expect(result.index).toBeNull(); + expect(result.status).toBe('baselineBuilding'); + expect(result.healthKitUsedForScore).toBe(false); + }); + + test('produces a confidence-capped provisional result from five measurements', () => { + const baseline = calculatePersonalBaseline(makeBaselineMeasurements(5)); + const result = calculatePotentialStress({...commonInput, baseline}); + + expect(result.index).not.toBeNull(); + expect(result.status).toBe('provisional'); + expect(result.confidence).toBeLessThanOrEqual(0.69); + expect(result.limitations).toContain('provisionalBaseline'); + }); + + test('combines elevated BPM and lower reliable PRV against a personalized baseline', () => { + const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10)); + const result = calculatePotentialStress({...commonInput, baseline}); + + expect(result.status).toBe('personalized'); + expect(result.index).toBeGreaterThan(80); + expect(result.prvContribution).not.toBeNull(); + expect(result.confidence).toBeGreaterThan(0.8); + }); + + test('uses BPM alone and strongly lowers confidence when PRV is unreliable', () => { + const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10)); + const reliable = calculatePotentialStress({...commonInput, baseline}); + const withoutPrv = calculatePotentialStress({ + ...commonInput, + baseline, + estimatedRmssdMs: null, + prvReliable: false, + validIntervalCount: 0, + }); + + expect(withoutPrv.index).not.toBeNull(); + expect(withoutPrv.prvContribution).toBeNull(); + expect(withoutPrv.limitations).toContain('prvUnavailable'); + expect(withoutPrv.confidence).toBeLessThan(reliable.confidence); + }); + + test('rejects signal quality below the scoring threshold', () => { + const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10)); + const result = calculatePotentialStress({ + ...commonInput, + baseline, + signalQuality: 0.64, + }); + + expect(result.status).toBe('unavailable'); + expect(result.index).toBeNull(); + expect(result.limitations).toContain('signalQualityTooLow'); + }); + + test('does not compare a non-resting measurement to the resting baseline', () => { + const baseline = calculatePersonalBaseline(makeBaselineMeasurements(10)); + const result = calculatePotentialStress({ + ...commonInput, + atRest: false, + baseline, + }); + + expect(result.status).toBe('unavailable'); + expect(result.index).toBeNull(); + expect(result.baselineMeasurementCount).toBe(10); + expect(result.limitations).toContain('measurementNotAtRest'); + }); + + test('keeps HealthKit weight locked at zero', () => { + expect(() => createStressIndexConfiguration({healthKitWeight: 0.1})).toThrow( + /healthKitWeight/, + ); + }); +}); + +describe('stress interpretation', () => { + test.each([ + [30, 'low'], + [31, 'moderate'], + [60, 'moderate'], + [61, 'high'], + [80, 'high'], + [81, 'veryHigh'], + ] as const)('maps %i to %s', (index, expectedLevel) => { + const interpretation = interpretPotentialStressIndex(index); + expect(interpretation.level).toBe(expectedLevel); + expect(interpretation.disclaimer).toContain('ne constitue pas'); + }); +}); diff --git a/src/features/ppg/tests/healthContextEvaluator.test.ts b/src/features/ppg/tests/healthContextEvaluator.test.ts new file mode 100644 index 0000000..8d242b2 --- /dev/null +++ b/src/features/ppg/tests/healthContextEvaluator.test.ts @@ -0,0 +1,97 @@ +import { + evaluateHealthKitContext, + evaluateHealthMetricSample, +} from '../health/healthContextEvaluator'; +import type { + HealthKitContext, + HealthMetricSample, + HealthMetricType, +} from '../health/healthTypes'; + +const NOW = new Date('2026-07-10T12:00:00.000Z'); + +const sample = ( + type: HealthMetricType, + endDate: string, + sourceBundleIdentifier = 'com.apple.health', +): HealthMetricSample => ({ + type, + value: type === 'hrvSdnn' ? 48 : 64, + unit: type === 'hrvSdnn' ? 'ms' : 'count/min', + startDate: endDate, + endDate, + sourceName: 'Apple Health', + sourceBundleIdentifier, + deviceName: 'iPhone', +}); + +const context = (overrides: Partial = {}): HealthKitContext => ({ + available: true, + authorizationRequestSettled: true, + latestRestingHeartRate: sample( + 'restingHeartRate', + '2026-07-10T08:00:00.000Z', + ), + latestHrvSdnn: sample('hrvSdnn', '2026-07-10T07:30:00.000Z'), + recentHeartRateSamples: [ + sample('heartRate', '2026-07-10T11:55:00.000Z', 'watch.source'), + sample('heartRate', '2026-07-10T11:50:00.000Z', 'phone.source'), + ], + contextConfidence: 1, + fetchedAt: NOW.toISOString(), + ...overrides, +}); + +describe('HealthKit context evaluation', () => { + test('never presents even fresh HealthKit data as the current camera measurement', () => { + const evaluation = evaluateHealthMetricSample( + sample('heartRate', '2026-07-10T11:59:00.000Z'), + NOW, + ); + + expect(evaluation.relevance).toBe('freshContext'); + expect(evaluation.usableAsContext).toBe(true); + expect(evaluation.canRepresentCurrentMeasurement).toBe(false); + }); + + test('marks old heart-rate samples as stale', () => { + const evaluation = evaluateHealthMetricSample( + sample('heartRate', '2026-07-08T11:00:00.000Z'), + NOW, + ); + + expect(evaluation.relevance).toBe('stale'); + expect(evaluation.confidence).toBe(0); + }); + + test('keeps sources distinct and excludes HealthKit from the score', () => { + const evaluation = evaluateHealthKitContext(context(), NOW); + + expect(evaluation.hasUsableContext).toBe(true); + expect(evaluation.healthKitUsedForScore).toBe(false); + expect(evaluation.sourceBundleIdentifiers).toEqual([ + 'com.apple.health', + 'phone.source', + 'watch.source', + ]); + expect(evaluation.latestHrvSdnn?.sample.type).toBe('hrvSdnn'); + }); + + test('does not block the app when HealthKit is unavailable or not requested', () => { + const unavailable = evaluateHealthKitContext( + context({available: false, authorizationRequestSettled: false}), + NOW, + ); + const notRequested = evaluateHealthKitContext( + context({authorizationRequestSettled: false}), + NOW, + ); + + expect(unavailable.contextConfidence).toBe(0); + expect(unavailable.issues).toContain('healthKitUnavailable'); + expect(notRequested.contextConfidence).toBe(0); + expect(notRequested.issues).toContain( + 'healthKitAuthorizationNotRequested', + ); + }); +}); diff --git a/src/features/ppg/tests/historyAnalytics.test.ts b/src/features/ppg/tests/historyAnalytics.test.ts new file mode 100644 index 0000000..ac7ec83 --- /dev/null +++ b/src/features/ppg/tests/historyAnalytics.test.ts @@ -0,0 +1,54 @@ +import {buildDailyTrends, buildFeelingCorrelation} from '../history/historyAnalytics'; +import type {StoredMeasurement} from '../storage/storageTypes'; +import {makeProcessingResult} from './syntheticMeasurements'; + +const makeMeasurement = ( + id: string, + measuredAt: string, + bpm: number, + feeling: 1 | 2 | 3 | 4 | 5, +): StoredMeasurement => ({ + schemaVersion: 1, + id, + measuredAt, + acquisition: {cameraPosition: 'front', torchUsed: false}, + atRest: true, + baselineEligible: true, + cameraResult: (() => { + const result = makeProcessingResult(); + return {...result, bpm: {...result.bpm, bpm}}; + })(), + potentialStress: { + index: 20, confidence: 0.8, status: 'provisional', bpmContribution: 0, + prvContribution: 0, signalQuality: 0.9, baselineMeasurementCount: 5, + healthKitUsedForScore: false, limitations: [], + }, + healthKitContext: null, + selfReport: { + feeling, posture: 'seated', recentExercise: false, recentCaffeine: false, + recentMeal: false, movementDuringMeasurement: false, phoneUnusuallyWarm: false, + recordedAt: measuredAt, + }, +}); + +describe('history analytics', () => { + test('aggregates multiple measurements into chronological daily points', () => { + const now = Date.parse('2026-07-17T12:00:00.000Z'); + const trends = buildDailyTrends([ + makeMeasurement('a', '2026-07-16T08:00:00.000Z', 70, 3), + makeMeasurement('b', '2026-07-16T18:00:00.000Z', 80, 4), + makeMeasurement('c', '2026-07-17T08:00:00.000Z', 90, 4), + ], 30, now); + expect(trends.map(point => point.day)).toEqual(['2026-07-16', '2026-07-17']); + expect(trends[0]).toMatchObject({bpm: 75, count: 2}); + }); + + test('groups descriptive BPM and RMSSD averages by feeling', () => { + const correlation = buildFeelingCorrelation([ + makeMeasurement('a', '2026-07-16T08:00:00.000Z', 70, 4), + makeMeasurement('b', '2026-07-16T18:00:00.000Z', 80, 4), + ]); + expect(correlation).toHaveLength(1); + expect(correlation[0]).toMatchObject({feeling: 4, averageBpm: 75, count: 2}); + }); +}); diff --git a/src/features/ppg/tests/measurementConfiguration.test.ts b/src/features/ppg/tests/measurementConfiguration.test.ts new file mode 100644 index 0000000..0521137 --- /dev/null +++ b/src/features/ppg/tests/measurementConfiguration.test.ts @@ -0,0 +1,26 @@ +import { + createMeasurementConfiguration, + DEFAULT_MEASUREMENT_CONFIGURATION, +} from '../measurement/measurementConfiguration'; + +describe('measurement configuration', () => { + test('uses a roughly sixty-second contact flow and a six-Hz UI cadence', () => { + expect( + DEFAULT_MEASUREMENT_CONFIGURATION.stabilizationSeconds + + DEFAULT_MEASUREMENT_CONFIGURATION.measurementSeconds, + ).toBe(60); + expect( + DEFAULT_MEASUREMENT_CONFIGURATION.targetSampleRate / + DEFAULT_MEASUREMENT_CONFIGURATION.emitEveryFrames, + ).toBe(6); + }); + + test('rejects a band above Nyquist', () => { + expect(() => + createMeasurementConfiguration({ + targetSampleRate: 30, + maximumFrequencyHz: 16, + }), + ).toThrow(/Nyquist/); + }); +}); diff --git a/src/features/ppg/tests/measurementCoordinator.test.ts b/src/features/ppg/tests/measurementCoordinator.test.ts new file mode 100644 index 0000000..59b410f --- /dev/null +++ b/src/features/ppg/tests/measurementCoordinator.test.ts @@ -0,0 +1,94 @@ +import type {NativePPGProcessingResult} from 'react-native-haptika-ppg'; + +import {mapNativeProcessingResult} from '../measurement/measurementCoordinator'; + +const nativeResult = (): NativePPGProcessingResult => ({ + valid: false, + invalidReason: 'Signal exploitable insuffisant', + bpm: { + bpm: 72, + temporalBpm: Number.NaN, + frequencyBpm: 72.4, + methodAgreementBpm: 0.4, + validPeakCount: 19.7, + rejectedPeakCount: 8.2, + confidence: 0.51, + selectedChannel: 'red-inverted', + }, + prv: { + estimatedRmssdMs: Number.POSITIVE_INFINITY, + estimatedSdnnMs: 31, + meanIpiMs: 833, + medianIpiMs: 829, + pnn50Percent: 7, + rangeIpiMs: 118, + modeIpiMs: 825, + amo50Percent: 42, + coefficientOfVariation: 0.05, + validIntervalCount: 18.8, + rejectedIntervalCount: 7.4, + rejectedIntervalRatio: 0.28, + reliable: false, + }, + quality: { + score: 0.43, + level: 'invalid', + usableDurationSeconds: 21.5, + droppedFrameRatio: 0.08, + saturationRatio: 0.12, + rejectedIntervalRatio: 0.28, + signalToNoiseRatio: 3.2, + motionArtifactRatio: 0.31, + methodAgreementScore: 0.6, + timestampJitterRatio: 0.04, + longestGapSeconds: 0.38, + issues: ['motionArtifacts', 'futureNativeDiagnostic'], + }, + startedAtMonotonicSeconds: 100, + endedAtMonotonicSeconds: 160, + rawSampleCount: 1_642.4, + resampledSampleCount: 5_501.7, +}); + +describe('measurementCoordinator diagnostics', () => { + test('preserves acquisition diagnostics used to explain an invalid result', () => { + const result = mapNativeProcessingResult(nativeResult()); + + expect(result).toMatchObject({ + valid: false, + invalidReason: 'Signal exploitable insuffisant', + rawSampleCount: 1_642, + resampledSampleCount: 5_502, + quality: { + usableDurationSeconds: 21.5, + droppedFrameRatio: 0.08, + saturationRatio: 0.12, + rejectedIntervalRatio: 0.28, + signalToNoiseRatio: 3.2, + motionArtifactRatio: 0.31, + timestampJitterRatio: 0.04, + longestGapSeconds: 0.38, + }, + bpm: { + selectedChannel: 'red-inverted', + validPeakCount: 20, + rejectedPeakCount: 8, + }, + prv: { + validIntervalCount: 19, + rejectedIntervalCount: 7, + }, + }); + }); + + test('normalizes non-finite values and unknown native issues safely', () => { + const result = mapNativeProcessingResult(nativeResult()); + + expect(result.bpm.temporalBpm).toBeNull(); + expect(result.prv.estimatedRmssdMs).toBeNull(); + expect(result.quality.issues).toEqual([ + 'motionArtifacts', + 'nonFiniteProcessingResult', + ]); + }); +}); diff --git a/src/features/ppg/tests/measurementMachine.test.ts b/src/features/ppg/tests/measurementMachine.test.ts new file mode 100644 index 0000000..ea626b7 --- /dev/null +++ b/src/features/ppg/tests/measurementMachine.test.ts @@ -0,0 +1,238 @@ +import { + createInitialMeasurementMachine, + transitionMeasurement, +} from '../measurement/measurementMachine'; +import type { + MeasurementEvent, + MeasurementMachineSnapshot, +} from '../measurement/measurementTypes'; +import {makeProcessingResult} from './syntheticMeasurements'; + +const send = ( + snapshot: MeasurementMachineSnapshot, + ...events: readonly MeasurementEvent[] +): MeasurementMachineSnapshot => + events.reduce(transitionMeasurement, snapshot); + +describe('measurement machine', () => { + test('runs the complete camera-only lifecycle', () => { + const result = makeProcessingResult(); + const snapshot = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'CAMERA_READY'}, + {type: 'CONTACT_CHANGED', contactState: 'valid'}, + {type: 'STABILIZATION_COMPLETE'}, + {type: 'ACQUISITION_COMPLETE'}, + {type: 'PROCESSING_SUCCEEDED', result}, + ); + + expect(snapshot.value).toBe('completed'); + expect(snapshot.context.result).toEqual(result); + expect(snapshot.context.attempt).toBe(1); + }); + + test('continues when the optional HealthKit request fails', () => { + const snapshot = send( + createInitialMeasurementMachine(), + {type: 'START', requestHealthKit: true}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + ); + expect(snapshot.value).toBe('requestingHealthPermission'); + + const afterFailure = transitionMeasurement(snapshot, { + type: 'HEALTH_PERMISSION_RESOLVED', + status: 'requestFailed', + }); + expect(afterFailure.value).toBe('preparingCamera'); + expect(afterFailure.context.healthPermission).toBe('requestFailed'); + }); + + test('a rejected camera permission retry returns through idle before requesting again', () => { + const denied = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_DENIED'}, + ); + expect(denied.value).toBe('error'); + + const retrying = transitionMeasurement(denied, {type: 'RETRY'}); + expect(retrying.value).toBe('idle'); + expect(retrying.context.attempt).toBe(1); + + const requestingAgain = transitionMeasurement(retrying, {type: 'START'}); + expect(requestingAgain.value).toBe('requestingCameraPermission'); + expect(requestingAgain.context.attempt).toBe(2); + }); + + test.each(['waitingForFinger', 'measuring'] as const)( + 'routes a camera failure from %s to the global error state', + activeState => { + let active = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'CAMERA_READY'}, + ); + if (activeState === 'measuring') { + active = send( + active, + {type: 'CONTACT_CHANGED', contactState: 'valid'}, + {type: 'STABILIZATION_COMPLETE'}, + ); + } + + const failed = transitionMeasurement(active, { + type: 'FAIL', + failure: { + code: 'cameraRuntime', + message: 'Camera runtime failure', + recoverable: true, + }, + }); + + expect(failed.value).toBe('error'); + expect(failed.context.failure?.code).toBe('cameraRuntime'); + }, + ); + + test('keeps stabilization and acquisition running when contact fluctuates', () => { + const measuring = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'CAMERA_READY'}, + {type: 'CONTACT_CHANGED', contactState: 'valid'}, + {type: 'STABILIZATION_COMPLETE'}, + ); + expect(measuring.value).toBe('measuring'); + + const waiting = transitionMeasurement(measuring, { + type: 'CONTACT_CHANGED', + contactState: 'unstable', + }); + expect(waiting.value).toBe('measuring'); + + const stillMeasuring = transitionMeasurement(waiting, { + type: 'CONTACT_CHANGED', + contactState: 'valid', + }); + expect(stillMeasuring.value).toBe('measuring'); + }); + + test('allows stabilization to complete even when the latest frame is unstable', () => { + const stabilizing = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'CAMERA_READY'}, + {type: 'CONTACT_CHANGED', contactState: 'valid'}, + {type: 'CONTACT_CHANGED', contactState: 'unstable'}, + ); + expect(stabilizing.value).toBe('stabilizing'); + expect( + transitionMeasurement(stabilizing, {type: 'STABILIZATION_COMPLETE'}).value, + ).toBe('measuring'); + }); + + test.each(['serious', 'critical'] as const)( + 'stops immediately for a %s thermal state', + thermalState => { + const measuring = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'CAMERA_READY'}, + ); + const stopped = transitionMeasurement(measuring, { + type: 'THERMAL_STATE_CHANGED', + thermalState, + }); + + expect(stopped.value).toBe('thermalStop'); + expect(stopped.context.failure?.code).toBe( + thermalState === 'critical' ? 'thermalCritical' : 'thermalSerious', + ); + expect(stopped.context.failure?.message).toMatch(/iPhone|chaud/); + }, + ); + + test('cannot reset past an unsafe thermal state', () => { + const stopped = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'THERMAL_STATE_CHANGED', thermalState: 'serious'}, + ); + + const reset = transitionMeasurement(stopped, {type: 'RESET'}); + expect(reset.value).toBe('thermalStop'); + + const cooled = transitionMeasurement(reset, { + type: 'THERMAL_STATE_CHANGED', + thermalState: 'fair', + }); + expect(transitionMeasurement(cooled, {type: 'RETRY'}).value).toBe( + 'preparingCamera', + ); + }); + + test('never completes an invalid native result', () => { + const invalidResult = makeProcessingResult({ + valid: false, + invalidReason: 'Signal insuffisant', + quality: { + ...makeProcessingResult().quality, + score: 0.4, + level: 'invalid', + }, + }); + const snapshot = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'CAMERA_READY'}, + {type: 'CONTACT_CHANGED', contactState: 'valid'}, + {type: 'STABILIZATION_COMPLETE'}, + {type: 'ACQUISITION_COMPLETE'}, + {type: 'PROCESSING_SUCCEEDED', result: invalidResult}, + ); + + expect(snapshot.value).toBe('invalid'); + expect(snapshot.context.result?.valid).toBe(false); + }); + + test('keeps a conservative BPM when PRV quality is poor', () => { + const partialResult = makeProcessingResult({ + valid: true, + bpm: { + ...makeProcessingResult().bpm, + bpm: 68, + }, + quality: { + ...makeProcessingResult().quality, + score: 0.52, + level: 'poor', + }, + prv: { + ...makeProcessingResult().prv, + reliable: false, + }, + }); + const snapshot = send( + createInitialMeasurementMachine(), + {type: 'START'}, + {type: 'CAMERA_PERMISSION_GRANTED'}, + {type: 'CAMERA_READY'}, + {type: 'CONTACT_CHANGED', contactState: 'valid'}, + {type: 'STABILIZATION_COMPLETE'}, + {type: 'ACQUISITION_COMPLETE'}, + {type: 'PROCESSING_SUCCEEDED', result: partialResult}, + ); + + expect(snapshot.value).toBe('completed'); + expect(snapshot.context.result?.bpm.bpm).toBe(68); + expect(snapshot.context.result?.prv.reliable).toBe(false); + }); +}); diff --git a/src/features/ppg/tests/metricInsights.test.ts b/src/features/ppg/tests/metricInsights.test.ts new file mode 100644 index 0000000..4515951 --- /dev/null +++ b/src/features/ppg/tests/metricInsights.test.ts @@ -0,0 +1,63 @@ +import {buildMetricInsights} from '../insights/metricInsights'; +import type {StoredMeasurement} from '../storage/storageTypes'; +import {makeProcessingResult} from './syntheticMeasurements'; + +const stored = (index: number, bpm: number, rmssd: number): StoredMeasurement => ({ + schemaVersion: 1, + algorithmVersion: 'test', + acquisition: {cameraPosition: 'front', torchUsed: false}, + id: `stored-${index}`, + measuredAt: new Date(Date.UTC(2026, 6, index + 1, 8)).toISOString(), + atRest: true, + baselineEligible: true, + cameraResult: makeProcessingResult({ + bpm: {...makeProcessingResult().bpm, bpm}, + prv: {...makeProcessingResult().prv, estimatedRmssdMs: rmssd}, + }), + potentialStress: { + index: null, + status: 'baselineBuilding', + confidence: 0, + bpmContribution: null, + prvContribution: null, + signalQuality: 0.9, + baselineMeasurementCount: 0, + healthKitUsedForScore: false, + limitations: [], + }, + healthKitContext: null, + selfReport: null, +}); + +describe('buildMetricInsights', () => { + it('uses real historical measurements and creates personal ranges after five values', () => { + const history = [ + stored(0, 70, 40), + stored(1, 71, 41), + stored(2, 69, 39), + stored(3, 72, 42), + stored(4, 70, 40), + ]; + const insights = buildMetricInsights(history[4]!.cameraResult, history); + const bpm = insights.find(insight => insight.key === 'bpm'); + const rmssd = insights.find(insight => insight.key === 'rmssd'); + + expect(bpm?.trend).toHaveLength(5); + expect(bpm?.referenceRange).not.toBeNull(); + expect(bpm?.status).toBe('Dans votre repère habituel'); + expect(rmssd?.trend.map(point => point.value)).toEqual([40, 41, 39, 42, 40]); + }); + + it('never publishes PRV insights when the current PRV is unreliable', () => { + const current = makeProcessingResult({ + prv: {...makeProcessingResult().prv, reliable: false}, + }); + const insights = buildMetricInsights(current, []); + + expect(insights.find(insight => insight.key === 'bpm')?.value).toBe(72); + expect(insights.find(insight => insight.key === 'rmssd')?.value).toBeNull(); + expect(insights.find(insight => insight.key === 'rmssd')?.status).toBe( + 'Signal insuffisant', + ); + }); +}); diff --git a/src/features/ppg/tests/storage.test.ts b/src/features/ppg/tests/storage.test.ts new file mode 100644 index 0000000..c6b547e --- /dev/null +++ b/src/features/ppg/tests/storage.test.ts @@ -0,0 +1,215 @@ +import type { + MeasurementSelfReport, + StoredMeasurement, +} from '../storage/storageTypes'; +import {makeProcessingResult} from './syntheticMeasurements'; + +const mockStore = new Map(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { + mockStore.set(key, value); + }), + }, +})); + +import { + exportMeasurementsAsCSV, + exportMeasurementsAsJSON, + measurementRepository, + toBaselineMeasurement, +} from '../storage/measurementRepository'; +import {settingsRepository} from '../storage/settingsRepository'; + +const makeStoredMeasurement = (id: string): StoredMeasurement => ({ + schemaVersion: 1, + acquisition: {cameraPosition: 'front', torchUsed: false}, + id, + measuredAt: '2026-07-10T08:00:00.000Z', + atRest: true, + baselineEligible: true, + cameraResult: makeProcessingResult(), + potentialStress: { + index: null, + confidence: 0, + status: 'baselineBuilding', + bpmContribution: null, + prvContribution: null, + signalQuality: 0.91, + baselineMeasurementCount: 0, + healthKitUsedForScore: false, + limitations: ['baselineBuilding'], + }, + healthKitContext: null, + selfReport: null, +}); + +describe('local storage repositories', () => { + beforeEach(() => mockStore.clear()); + + it('stores numeric measurement results without camera frames', async () => { + const measurement = makeStoredMeasurement('m-1'); + await measurementRepository.add(measurement); + + const stored = await measurementRepository.getAll(); + expect(stored).toEqual([measurement]); + expect(JSON.stringify(stored)).not.toContain('pixelBuffer'); + expect(JSON.stringify(stored)).not.toContain('image'); + }); + + it('stores self-report separately without changing the result', async () => { + const measurement = makeStoredMeasurement('m-2'); + await measurementRepository.add(measurement); + await measurementRepository.attachSelfReport('m-2', { + feeling: 4, + posture: 'seated', + recentExercise: false, + recentCaffeine: true, + recentMeal: false, + movementDuringMeasurement: false, + phoneUnusuallyWarm: false, + recordedAt: '2026-07-10T08:02:00.000Z', + }); + + const [stored] = await measurementRepository.getAll(); + expect(stored?.selfReport?.feeling).toBe(4); + expect(stored?.cameraResult).toEqual(measurement.cameraResult); + expect(stored?.potentialStress).toEqual(measurement.potentialStress); + expect(stored?.baselineEligible).toBe(false); + }); + + it('serializes a result write with an immediately submitted self-report', async () => { + const measurement = makeStoredMeasurement('m-concurrent'); + const selfReport: MeasurementSelfReport = { + feeling: 3, + posture: 'seated', + recentExercise: false, + recentCaffeine: false, + recentMeal: false, + movementDuringMeasurement: false, + phoneUnusuallyWarm: false, + recordedAt: '2026-07-10T08:03:00.000Z', + }; + + await Promise.all([ + measurementRepository.add(measurement), + measurementRepository.attachSelfReport(measurement.id, selfReport), + ]); + + expect(await measurementRepository.getAll()).toEqual([ + {...measurement, selfReport}, + ]); + }); + + it('excludes a contextually non-comparable measurement from future baselines', async () => { + const measurement = makeStoredMeasurement('m-context'); + await measurementRepository.add(measurement); + await measurementRepository.attachSelfReport(measurement.id, { + feeling: 2, + posture: 'seated', + recentExercise: true, + recentCaffeine: null, + recentMeal: null, + movementDuringMeasurement: false, + phoneUnusuallyWarm: false, + recordedAt: '2026-07-10T08:04:00.000Z', + }); + + const [stored] = await measurementRepository.getAll(); + expect(stored?.baselineEligible).toBe(false); + expect(stored && toBaselineMeasurement(stored).valid).toBe(false); + }); + + it('never re-enables a measurement explicitly excluded from the baseline', async () => { + const measurement = { + ...makeStoredMeasurement('m-after-regulation'), + baselineEligible: false, + }; + await measurementRepository.add(measurement); + await measurementRepository.attachSelfReport(measurement.id, { + feeling: 2, + posture: 'seated', + recentExercise: false, + recentCaffeine: false, + recentMeal: false, + movementDuringMeasurement: false, + phoneUnusuallyWarm: false, + recordedAt: '2026-07-10T08:05:00.000Z', + }); + + const [stored] = await measurementRepository.getAll(); + expect(stored?.baselineEligible).toBe(false); + }); + + it('drops malformed nested rows instead of exposing them to the history UI', async () => { + const measurement = makeStoredMeasurement('m-valid'); + await measurementRepository.add(measurement); + const [key] = [...mockStore.keys()]; + expect(key).toBeDefined(); + mockStore.set( + key ?? 'missing', + JSON.stringify([ + measurement, + {...measurement, id: 'm-corrupt', cameraResult: {valid: true}}, + ]), + ); + + expect(await measurementRepository.getAll()).toEqual([measurement]); + }); + + it('keeps HealthKit preference optional and maps camera provenance', async () => { + expect((await settingsRepository.get()).includeHealthKitContext).toBe(false); + await settingsRepository.setIncludeHealthKitContext(true); + expect((await settingsRepository.get()).includeHealthKitContext).toBe(true); + + const baselineInput = toBaselineMeasurement(makeStoredMeasurement('m-3')); + expect(baselineInput.source).toBe('cameraPPG'); + expect(baselineInput.bpm).toBe(72); + expect(baselineInput.estimatedRmssdMs).toBe(42); + expect(baselineInput.cameraPosition).toBe('front'); + }); + + it('reads, deletes and clears measurements without leaving duplicates', async () => { + await measurementRepository.add(makeStoredMeasurement('m-delete')); + await measurementRepository.add(makeStoredMeasurement('m-keep')); + expect((await measurementRepository.getById('m-delete'))?.id).toBe('m-delete'); + await measurementRepository.deleteById('m-delete'); + expect((await measurementRepository.getAll()).map(item => item.id)).toEqual(['m-keep']); + await measurementRepository.clearAll(); + expect(await measurementRepository.getAll()).toEqual([]); + }); + + it('applies and persists the selected retention policy', async () => { + const recent = {...makeStoredMeasurement('recent'), measuredAt: new Date().toISOString()}; + const old = {...makeStoredMeasurement('old'), measuredAt: '2020-01-01T08:00:00.000Z'}; + await measurementRepository.add(old); + await measurementRepository.add(recent); + await measurementRepository.setRetentionPolicy('30days'); + expect(await measurementRepository.getRetentionPolicy()).toBe('30days'); + expect((await measurementRepository.getAll()).map(item => item.id)).toEqual(['recent']); + }); + + it('migrates legacy rows to explicit unknown camera provenance', async () => { + const legacy: Record = {...makeStoredMeasurement('legacy')}; + delete legacy.acquisition; + const measurementKey = '@haptika/ppg/v1/measurements'; + mockStore.set(measurementKey, JSON.stringify([legacy])); + expect((await measurementRepository.getAll())[0]?.acquisition).toEqual({ + cameraPosition: 'unknown', + torchUsed: null, + }); + }); + + it('exports portable JSON and spreadsheet-friendly CSV without images', () => { + const measurement = makeStoredMeasurement('m-export'); + const json = exportMeasurementsAsJSON([measurement]); + const csv = exportMeasurementsAsCSV([measurement]); + expect(JSON.parse(json).measurements[0].id).toBe('m-export'); + expect(csv).toContain('"camera"'); + expect(csv).toContain('"front"'); + expect(json).not.toContain('pixelBuffer'); + }); +}); diff --git a/src/features/ppg/tests/syntheticMeasurements.ts b/src/features/ppg/tests/syntheticMeasurements.ts new file mode 100644 index 0000000..dd0c7a4 --- /dev/null +++ b/src/features/ppg/tests/syntheticMeasurements.ts @@ -0,0 +1,75 @@ +import type {PPGProcessingResult} from '../measurement/measurementTypes'; +import type {BaselineMeasurement} from '../stress/baselineTypes'; + +const BPM_OFFSETS = [0, 1, -1, 0, 2, -2]; +const RMSSD_OFFSETS = [0, 2, -2, 1, -1, 0]; + +export const makeBaselineMeasurement = ( + index: number, + overrides: Partial = {}, +): BaselineMeasurement => ({ + id: `measurement-${index}`, + source: 'cameraPPG', + valid: true, + measuredAt: new Date(Date.UTC(2026, 5, 1 + index, 8)).toISOString(), + bpm: 60 + (BPM_OFFSETS[index % BPM_OFFSETS.length] ?? 0), + estimatedRmssdMs: + 50 + (RMSSD_OFFSETS[index % RMSSD_OFFSETS.length] ?? 0), + prvReliable: true, + validIntervalCount: 48, + signalQuality: 0.9, + atRest: true, + measurementHourLocal: 8, + ...overrides, +}); + +export const makeBaselineMeasurements = ( + count: number, +): BaselineMeasurement[] => + Array.from({length: count}, (_, index) => makeBaselineMeasurement(index)); + +export const makeProcessingResult = ( + overrides: Partial = {}, +): PPGProcessingResult => ({ + valid: true, + invalidReason: null, + bpm: { + bpm: 72, + temporalBpm: 71.8, + frequencyBpm: 72.1, + methodAgreementBpm: 0.3, + validPeakCount: 64, + rejectedPeakCount: 2, + confidence: 0.92, + selectedChannel: 'green', + }, + prv: { + estimatedRmssdMs: 42, + estimatedSdnnMs: 48, + medianIpiMs: 833, + coefficientOfVariation: 0.06, + validIntervalCount: 62, + rejectedIntervalCount: 2, + rejectedIntervalRatio: 0.03, + reliable: true, + }, + quality: { + score: 0.91, + level: 'good', + usableDurationSeconds: 55, + droppedFrameRatio: 0.01, + saturationRatio: 0.03, + rejectedIntervalRatio: 0.03, + signalToNoiseRatio: 12, + motionArtifactRatio: 0.02, + methodAgreementScore: 0.95, + timestampJitterRatio: 0.01, + longestGapSeconds: 0.05, + issues: [], + }, + startedAtMonotonicSeconds: 1_000, + endedAtMonotonicSeconds: 1_060, + rawSampleCount: 1_800, + resampledSampleCount: 1_650, + ...overrides, +}); diff --git a/src/features/ppg/tests/usePPGMeasurement.test.tsx b/src/features/ppg/tests/usePPGMeasurement.test.tsx new file mode 100644 index 0000000..d5950ae --- /dev/null +++ b/src/features/ppg/tests/usePPGMeasurement.test.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import {AppState} from 'react-native'; +import ReactTestRenderer from 'react-test-renderer'; +import { + haptikaPPG, + type PPGFrameUpdate, +} from 'react-native-haptika-ppg'; + +import { + usePPGMeasurement, + type PPGMeasurementController, +} from '../measurement/usePPGMeasurement'; + +jest.mock('react-native-haptika-ppg', () => ({ + haptikaPPG: { + startMeasurement: jest.fn(), + processFrame: jest.fn(), + finishMeasurement: jest.fn(), + cancelMeasurement: jest.fn(), + getThermalState: jest.fn(() => 'nominal'), + }, + haptikaHealthKit: { + isAvailable: jest.fn(() => false), + requestReadAuthorization: jest.fn(async () => false), + }, +})); + +const nativePPG = haptikaPPG as jest.Mocked; +let controller: PPGMeasurementController; +let renderer: ReactTestRenderer.ReactTestRenderer | null = null; +let memoryWarningHandler: (() => void) | null = null; + +function Harness(): null { + controller = usePPGMeasurement(); + return null; +} + +const frameUpdate = ( + thermalState: PPGFrameUpdate['thermalState'], + overrides: Partial = {}, +): PPGFrameUpdate => ({ + timestampSeconds: 1, + contactState: 'absent', + contactConfidence: 0, + signalLevel: 0, + saturationRatio: 0, + motionArtifactScore: 0, + collectedSampleCount: 0, + usableDurationSeconds: 0, + droppedFrameCount: 0, + thermalState, + waveformValue: 0, + beatSequence: 0, + acceptedBeats: [], + ...overrides, +}); + +async function startCamera(): Promise { + await ReactTestRenderer.act(async () => { + await controller.start({ + includeHealthKitContext: false, + sensitiveContact: true, + requestCameraPermission: async () => true, + }); + }); + ReactTestRenderer.act(() => controller.onCameraStarted()); +} + +describe('usePPGMeasurement safety coordination', () => { + beforeEach(async () => { + jest.clearAllMocks(); + memoryWarningHandler = null; + jest.spyOn(AppState, 'addEventListener').mockImplementation( + ((eventType: string, listener: () => void) => { + if (eventType === 'memoryWarning') memoryWarningHandler = listener; + return {remove: jest.fn()}; + }) as unknown as typeof AppState.addEventListener, + ); + nativePPG.getThermalState.mockReturnValue('nominal'); + await ReactTestRenderer.act(async () => { + renderer = ReactTestRenderer.create(); + }); + }); + + afterEach(() => { + if (renderer !== null) { + ReactTestRenderer.act(() => renderer?.unmount()); + renderer = null; + } + jest.restoreAllMocks(); + }); + + test('cancels the native session synchronously before publishing a camera failure', async () => { + await startCamera(); + expect(nativePPG.startMeasurement).toHaveBeenCalledWith( + expect.objectContaining({minimumContactConfidence: 0.45}), + ); + expect(controller.snapshot.value).toBe('stabilizing'); + + ReactTestRenderer.act(() => { + controller.onCameraError({ + code: 'cameraRuntime', + message: 'Frame processing failed', + }); + expect(nativePPG.cancelMeasurement).toHaveBeenCalledTimes(1); + }); + + expect(controller.snapshot.value).toBe('error'); + expect(controller.snapshot.context.failure?.code).toBe('cameraRuntime'); + }); + + test('re-reads native thermal state before leaving thermalStop', async () => { + await startCamera(); + ReactTestRenderer.act(() => controller.onFrameUpdate(frameUpdate('serious'))); + expect(controller.snapshot.value).toBe('thermalStop'); + + nativePPG.getThermalState.mockReturnValueOnce('serious'); + ReactTestRenderer.act(() => controller.retry()); + expect(controller.snapshot.value).toBe('thermalStop'); + + nativePPG.getThermalState.mockReturnValueOnce('fair'); + ReactTestRenderer.act(() => controller.retry()); + expect(controller.snapshot.value).toBe('preparingCamera'); + expect(nativePPG.getThermalState).toHaveBeenCalledTimes(2); + }); + + test('pauses useful progress without stopping capture when contact is unusable', async () => { + await startCamera(); + + ReactTestRenderer.act(() => + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: 10, + contactState: 'valid', + contactConfidence: 0.95, + usableDurationSeconds: 0, + }), + ), + ); + expect(controller.snapshot.value).toBe('stabilizing'); + + ReactTestRenderer.act(() => + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: 12, + contactState: 'unstable', + contactConfidence: 0.15, + usableDurationSeconds: 0, + motionArtifactScore: 1, + }), + ), + ); + expect(controller.snapshot.value).toBe('stabilizing'); + expect(controller.phaseProgress).toBe(0); + + ReactTestRenderer.act(() => + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: 15.1, + contactState: 'absent', + usableDurationSeconds: 0, + }), + ), + ); + expect(controller.snapshot.value).toBe('stabilizing'); + + ReactTestRenderer.act(() => { + for (let timestamp = 16; timestamp <= 21.5; timestamp += 0.5) { + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: timestamp, + contactState: 'valid', + contactConfidence: 0.9, + }), + ); + } + }); + expect(controller.snapshot.value).toBe('measuring'); + + ReactTestRenderer.act(() => + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: 32.1, + contactState: 'overexposed', + contactConfidence: 0.05, + usableDurationSeconds: 0, + saturationRatio: 1, + }), + ), + ); + const firstAcquisitionProgress = controller.phaseProgress; + expect(firstAcquisitionProgress).toBe(0); + expect(controller.snapshot.value).toBe('measuring'); + + ReactTestRenderer.act(() => + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: 54.1, + contactState: 'tooMuchPressure', + contactConfidence: 0.1, + usableDurationSeconds: 0, + }), + ), + ); + + expect(controller.snapshot.value).toBe('measuring'); + expect(controller.phaseProgress).toBe(firstAcquisitionProgress); + expect(nativePPG.cancelMeasurement).not.toHaveBeenCalled(); + }); + + test('finishes after fifty detected beats and the minimum acquisition time', async () => { + nativePPG.finishMeasurement.mockImplementationOnce(() => { + throw new Error('synthetic processing stop'); + }); + await startCamera(); + ReactTestRenderer.act(() => { + for (let timestamp = 10; timestamp <= 15.5; timestamp += 0.5) { + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: timestamp, + contactState: 'valid', + contactConfidence: 0.95, + beatSequence: 4, + usableDurationSeconds: timestamp - 10, + }), + ); + } + }); + expect(controller.snapshot.value).toBe('measuring'); + + ReactTestRenderer.act(() => + controller.onFrameUpdate( + frameUpdate('nominal', { + timestampSeconds: 60.7, + contactState: 'unstable', + beatSequence: 49, + usableDurationSeconds: 50.7, + collectedSampleCount: 1_500, + }), + ), + ); + + expect(controller.phaseProgress).toBe(1); + expect(nativePPG.finishMeasurement).toHaveBeenCalledTimes(1); + expect(controller.snapshot.value).not.toBe('measuring'); + }); + + test('stops and releases the native camera session on memory pressure', async () => { + await startCamera(); + expect(controller.snapshot.value).toBe('stabilizing'); + expect(memoryWarningHandler).not.toBeNull(); + + ReactTestRenderer.act(() => memoryWarningHandler?.()); + + expect(nativePPG.cancelMeasurement).toHaveBeenCalledTimes(1); + expect(controller.snapshot.value).toBe('error'); + expect(controller.snapshot.context.failure?.code).toBe('memoryPressure'); + }); +}); diff --git a/src/features/watch/watchAnalytics.ts b/src/features/watch/watchAnalytics.ts new file mode 100644 index 0000000..807664a --- /dev/null +++ b/src/features/watch/watchAnalytics.ts @@ -0,0 +1,57 @@ +import type { + WatchMeasurementComparisonV1, + WatchMeasurementSummaryV1, + WatchRegulationSessionV1, +} from './watchTypes'; + +export interface WatchDailyTrendV1 { + day: string; + averageBpm: number; + averageQuality: number; + count: number; +} + +const average = (values: readonly number[]): number => + values.reduce((sum, value) => sum + value, 0) / values.length; + +export function buildWatchDailyTrend( + measurements: readonly WatchMeasurementSummaryV1[], + days = 7, + now = Date.now(), +): WatchDailyTrendV1[] { + const cutoff = now - days * 86_400_000; + const groups = new Map(); + measurements.filter(item => item.valid && item.averageBpm !== null && Date.parse(item.endedAt) >= cutoff).forEach(item => { + const day = item.endedAt.slice(0, 10); + groups.set(day, [...(groups.get(day) ?? []), item]); + }); + return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([day, items]) => ({ + day, + averageBpm: average(items.map(item => item.averageBpm as number)), + averageQuality: average(items.map(item => item.quality)), + count: items.length, + })); +} + +export function compareWatchRegulationSession( + session: WatchRegulationSessionV1, + measurements: readonly WatchMeasurementSummaryV1[], +): WatchMeasurementComparisonV1 | null { + if (session.state !== 'completed' || session.beforeMeasurementId === null || session.afterMeasurementId === null) return null; + const before = measurements.find(item => item.id === session.beforeMeasurementId); + const after = measurements.find(item => item.id === session.afterMeasurementId); + if (before === undefined || after === undefined || !before.valid || !after.valid || before.averageBpm === null || after.averageBpm === null) return null; + const feeling = before.feeling === null || after.feeling === null ? null : { + before: before.feeling.score, + after: after.feeling.score, + delta: after.feeling.score - before.feeling.score, + }; + return { + beforeMeasurementId: before.id, + afterMeasurementId: after.id, + bpm: {before: before.averageBpm, after: after.averageBpm, delta: after.averageBpm - before.averageBpm}, + quality: {before: before.quality, after: after.quality, delta: after.quality - before.quality}, + feeling, + prvAvailable: false, + }; +} diff --git a/src/features/watch/watchBaseline.ts b/src/features/watch/watchBaseline.ts new file mode 100644 index 0000000..ad6549b --- /dev/null +++ b/src/features/watch/watchBaseline.ts @@ -0,0 +1,63 @@ +import {median, medianAbsoluteDeviation} from '../ppg/stress/baselineCalculator'; +import type {WatchMeasurementSummaryV1} from './watchTypes'; + +export type WatchBaselineStatus = 'building' | 'provisional' | 'personalized'; +export type WatchBaselineInterpretation = 'habitual' | 'slightlyDifferent' | 'clearlyDifferent'; + +export interface WatchBaselineV1 { + source: 'appleWatchOptical'; + status: WatchBaselineStatus; + validMeasurementCount: number; + medianBpm: number; + bpmMad: number; + medianQuality: number; + updatedAt: string; +} + +export interface WatchBaselineComparisonV1 { + interpretation: WatchBaselineInterpretation; + deltaBpm: number; + robustDistance: number; +} + +export function calculateWatchBaseline( + measurements: readonly WatchMeasurementSummaryV1[], +): WatchBaselineV1 | null { + const unique = new Map(measurements.map(item => [item.id, item])); + const accepted = [...unique.values()].filter(item => + item.valid && item.context.atRest && item.quality >= 0.65 && + item.averageBpm !== null && item.averageBpm >= 35 && item.averageBpm <= 220 && Number.isFinite(Date.parse(item.endedAt)), + ); + if (accepted.length === 0) return null; + const bpmValues = accepted.map(item => item.averageBpm as number); + const center = median(bpmValues) as number; + const mad = medianAbsoluteDeviation(bpmValues, center) as number; + return { + source: 'appleWatchOptical', + status: accepted.length >= 10 ? 'personalized' : accepted.length >= 5 ? 'provisional' : 'building', + validMeasurementCount: accepted.length, + medianBpm: center, + bpmMad: mad, + medianQuality: median(accepted.map(item => item.quality)) as number, + updatedAt: new Date(Math.max(...accepted.map(item => Date.parse(item.updatedAt)))).toISOString(), + }; +} + +export function compareWithWatchBaseline( + averageBpm: number, + baseline: WatchBaselineV1, +): WatchBaselineComparisonV1 { + const deltaBpm = averageBpm - baseline.medianBpm; + // A 3 BPM floor avoids classifying tiny changes as exceptional when MAD is zero. + const robustScale = Math.max(3, baseline.bpmMad * 1.4826); + const robustDistance = Math.abs(deltaBpm) / robustScale; + return { + deltaBpm, + robustDistance, + interpretation: robustDistance <= 1.5 + ? 'habitual' + : robustDistance <= 3 + ? 'slightlyDifferent' + : 'clearlyDifferent', + }; +} diff --git a/src/features/watch/watchContract.test.ts b/src/features/watch/watchContract.test.ts new file mode 100644 index 0000000..73bc74f --- /dev/null +++ b/src/features/watch/watchContract.test.ts @@ -0,0 +1,26 @@ +import fixtures from '../../../shared/watch-sync-fixtures.v1.json'; +import { + parseWatchEnvelope, + parseWatchMeasurement, + parseWatchTombstone, +} from './watchValidation'; + +describe('Watch sync contract V1 fixtures', () => { + it('accepts the common measurement envelope', () => { + const envelope = parseWatchEnvelope(fixtures.measurementEnvelope); + expect(envelope).not.toBeNull(); + const measurement = parseWatchMeasurement(envelope?.payload); + expect(measurement?.source).toBe('appleWatchOptical'); + expect(measurement?.averageBpm).toBe(71.5); + expect(measurement?.samples).toHaveLength(8); + expect(measurement?.valid).toBe(true); + }); + + it('accepts a deletion tombstone using the same wrapper', () => { + const envelope = parseWatchEnvelope(fixtures.tombstoneEnvelope); + expect(envelope?.kind).toBe('tombstone'); + expect(parseWatchTombstone(envelope?.payload)?.entityKind).toBe( + 'measurement', + ); + }); +}); diff --git a/src/features/watch/watchRepository.test.ts b/src/features/watch/watchRepository.test.ts new file mode 100644 index 0000000..a310849 --- /dev/null +++ b/src/features/watch/watchRepository.test.ts @@ -0,0 +1,88 @@ +import fixtures from '../../../shared/watch-sync-fixtures.v1.json'; +import type {WatchMeasurementSummaryV1, WatchSyncEnvelopeV1} from './watchTypes'; + +const mockStore = new Map(); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { mockStore.set(key, value); }), + }, +})); + +import {buildWatchDailyTrend} from './watchAnalytics'; +import {calculateWatchBaseline, compareWithWatchBaseline} from './watchBaseline'; +import {watchRepository} from './watchRepository'; + +const fixtureEnvelope = fixtures.measurementEnvelope as WatchSyncEnvelopeV1; + +describe('Watch repository and baseline V1', () => { + beforeEach(() => mockStore.clear()); + + test('imports a Watch measurement idempotently without touching camera storage', async () => { + expect((await watchRepository.importEnvelope(fixtureEnvelope)).status).toBe('applied'); + expect((await watchRepository.importEnvelope(fixtureEnvelope)).status).toBe('duplicate'); + expect((await watchRepository.getMeasurements())[0]?.averageBpm).toBe(71.5); + expect(mockStore.has('@haptika/ppg/v1/measurements')).toBe(false); + }); + + test('resolves upserts by updatedAt and lets an equally recent tombstone win', async () => { + await watchRepository.importEnvelope(fixtureEnvelope); + const older = { + ...fixtureEnvelope, + eventId: 'older-event', + payload: {...fixtureEnvelope.payload, updatedAt: '2026-07-20T08:59:59.000Z', averageBpm: 70}, + }; + expect((await watchRepository.importEnvelope(older)).status).toBe('stale'); + expect((await watchRepository.importEnvelope(fixtures.tombstoneEnvelope)).status).toBe('applied'); + expect(await watchRepository.getMeasurements()).toEqual([]); + const resurrect = {...fixtureEnvelope, eventId: 'resurrect-after-delete'}; + expect((await watchRepository.importEnvelope(resurrect)).status).toBe('stale'); + }); + + test('accepts an invalid Watch result with unavailable BPM metrics', async () => { + const invalid = { + ...fixtureEnvelope, + eventId: 'invalid-result-event', + payload: { + ...fixtureEnvelope.payload, + id: 'invalid-result', + currentBpm: null, + averageBpm: null, + minimumBpm: null, + maximumBpm: null, + samples: [], + coverage: 0, + quality: 0, + valid: false, + invalidReason: 'insufficientSamples', + }, + }; + expect((await watchRepository.importEnvelope(invalid)).status).toBe('applied'); + expect((await watchRepository.getMeasurements())[0]?.valid).toBe(false); + }); + + test('builds a separate robust Watch baseline after 5 and 10 measurements', () => { + const base = fixtureEnvelope.payload; + const make = (index: number): WatchMeasurementSummaryV1 => ({ + ...base, + id: `watch-${index}`, + updatedAt: `2026-07-${String(10 + index).padStart(2, '0')}T09:00:00.000Z`, + endedAt: `2026-07-${String(10 + index).padStart(2, '0')}T09:00:00.000Z`, + averageBpm: 70 + (index % 3), minimumBpm: 68, maximumBpm: 75, + }); + const provisional = calculateWatchBaseline(Array.from({length: 5}, (_, index) => make(index))); + expect(provisional?.status).toBe('provisional'); + const personalized = calculateWatchBaseline(Array.from({length: 10}, (_, index) => make(index))); + expect(personalized?.status).toBe('personalized'); + expect(personalized && compareWithWatchBaseline(71, personalized).interpretation).toBe('habitual'); + expect(personalized && compareWithWatchBaseline(100, personalized).interpretation).toBe('clearlyDifferent'); + }); + + test('builds Watch trends without PRV fields', () => { + const trends = buildWatchDailyTrend([fixtureEnvelope.payload], 7, Date.parse('2026-07-21T09:00:00.000Z')); + expect(trends).toEqual([{day: '2026-07-20', averageBpm: 71.5, averageQuality: 0.83, count: 1}]); + expect(trends[0]).not.toHaveProperty('rmssdMs'); + }); +}); diff --git a/src/features/watch/watchRepository.ts b/src/features/watch/watchRepository.ts new file mode 100644 index 0000000..d5b9452 --- /dev/null +++ b/src/features/watch/watchRepository.ts @@ -0,0 +1,224 @@ +import {readJSON, storageKeys, writeJSON} from '../ppg/storage/database'; +import type { + WatchMeasurementFeelingV1, + WatchMeasurementSummaryV1, + WatchRegulationSessionV1, + WatchSyncEnvelopeV1, + WatchTombstoneV1, +} from './watchTypes'; +import { + parseWatchEnvelope, + parseWatchMeasurement, + parseWatchRegulationSession, + parseWatchTombstone, +} from './watchValidation'; + +export type WatchImportStatus = 'applied' | 'duplicate' | 'stale' | 'invalid' | 'ignored'; + +export interface WatchImportResult { + eventId: string | null; + status: WatchImportStatus; +} + +let mutationQueue: Promise = Promise.resolve(); + +const enqueue = (operation: () => Promise): Promise => { + const pending = mutationQueue.then(operation); + mutationQueue = pending.then(() => undefined, () => undefined); + return pending; +}; + +const updatedAt = (value: {updatedAt: string}): number => Date.parse(value.updatedAt); + +async function readMeasurements(): Promise { + const raw = await readJSON(storageKeys.watchMeasurements, []); + if (!Array.isArray(raw)) return []; + const unique = new Map(); + raw.forEach(value => { + const parsed = parseWatchMeasurement(value); + const previous = parsed === null ? undefined : unique.get(parsed.id); + if (parsed !== null && (previous === undefined || updatedAt(parsed) > updatedAt(previous))) { + unique.set(parsed.id, parsed); + } + }); + return [...unique.values()].sort((a, b) => Date.parse(b.endedAt) - Date.parse(a.endedAt)); +} + +async function readSessions(): Promise { + const raw = await readJSON(storageKeys.watchRegulationSessions, []); + if (!Array.isArray(raw)) return []; + const unique = new Map(); + raw.forEach(value => { + const parsed = parseWatchRegulationSession(value); + const previous = parsed === null ? undefined : unique.get(parsed.id); + if (parsed !== null && (previous === undefined || updatedAt(parsed) > updatedAt(previous))) { + unique.set(parsed.id, parsed); + } + }); + return [...unique.values()].sort((a, b) => Date.parse(b.startedAt) - Date.parse(a.startedAt)); +} + +async function readTombstones(): Promise { + const raw = await readJSON(storageKeys.watchTombstones, []); + if (!Array.isArray(raw)) return []; + const unique = new Map(); + raw.forEach(value => { + const parsed = parseWatchTombstone(value); + const previous = parsed === null ? undefined : unique.get(`${parsed.entityKind}:${parsed.entityId}`); + if (parsed !== null && (previous === undefined || updatedAt(parsed) >= updatedAt(previous))) { + unique.set(`${parsed.entityKind}:${parsed.entityId}`, parsed); + } + }); + return [...unique.values()]; +} + +async function processedEvents(): Promise { + const raw = await readJSON(storageKeys.watchProcessedEvents, []); + return Array.isArray(raw) ? raw.filter((item): item is string => typeof item === 'string') : []; +} + +async function markProcessed(eventId: string, current: readonly string[]): Promise { + await writeJSON(storageKeys.watchProcessedEvents, [eventId, ...current.filter(id => id !== eventId)].slice(0, 1_000)); +} + +function tombstoneFor( + tombstones: readonly WatchTombstoneV1[], + entityKind: WatchTombstoneV1['entityKind'], + entityId: string, +): WatchTombstoneV1 | undefined { + return tombstones.find(item => item.entityKind === entityKind && item.entityId === entityId); +} + +async function upsertMeasurement( + candidate: WatchMeasurementSummaryV1, + tombstones: readonly WatchTombstoneV1[], +): Promise { + const tombstone = tombstoneFor(tombstones, 'measurement', candidate.id); + if (tombstone !== undefined && updatedAt(tombstone) >= updatedAt(candidate)) return 'stale'; + const current = await readMeasurements(); + const previous = current.find(item => item.id === candidate.id); + if (previous !== undefined && updatedAt(previous) >= updatedAt(candidate)) return 'stale'; + await writeJSON(storageKeys.watchMeasurements, [candidate, ...current.filter(item => item.id !== candidate.id)]); + return 'applied'; +} + +async function upsertSession( + candidate: WatchRegulationSessionV1, + tombstones: readonly WatchTombstoneV1[], +): Promise { + const tombstone = tombstoneFor(tombstones, 'regulationSession', candidate.id); + if (tombstone !== undefined && updatedAt(tombstone) >= updatedAt(candidate)) return 'stale'; + const current = await readSessions(); + const previous = current.find(item => item.id === candidate.id); + if (previous !== undefined && updatedAt(previous) >= updatedAt(candidate)) return 'stale'; + await writeJSON(storageKeys.watchRegulationSessions, [candidate, ...current.filter(item => item.id !== candidate.id)]); + return 'applied'; +} + +async function applyFeeling( + payload: unknown, + tombstones: readonly WatchTombstoneV1[], +): Promise { + if (typeof payload !== 'object' || payload === null) return 'invalid'; + const value = payload as Record; + if (typeof value.measurementId !== 'string' || typeof value.updatedAt !== 'string' || + !Number.isFinite(Date.parse(value.updatedAt)) || typeof value.feeling !== 'object' || value.feeling === null) return 'invalid'; + const feeling = value.feeling as Record; + if (![1, 2, 3, 4, 5].includes(Number(feeling.score)) || typeof feeling.recordedAt !== 'string' || + !Number.isFinite(Date.parse(feeling.recordedAt))) return 'invalid'; + const current = await readMeasurements(); + const measurement = current.find(item => item.id === value.measurementId); + if (measurement === undefined || tombstoneFor(tombstones, 'measurement', measurement.id) !== undefined) return 'ignored'; + if (updatedAt(measurement) > Date.parse(value.updatedAt)) return 'stale'; + const next: WatchMeasurementSummaryV1 = { + ...measurement, + feeling: feeling as unknown as WatchMeasurementFeelingV1, + updatedAt: value.updatedAt, + }; + await writeJSON(storageKeys.watchMeasurements, [next, ...current.filter(item => item.id !== next.id)]); + return 'applied'; +} + +async function applyTombstone(candidate: WatchTombstoneV1): Promise { + const tombstones = await readTombstones(); + const previous = tombstoneFor(tombstones, candidate.entityKind, candidate.entityId); + if (previous !== undefined && updatedAt(previous) > updatedAt(candidate)) return 'stale'; + const next = [candidate, ...tombstones.filter(item => item.entityKind !== candidate.entityKind || item.entityId !== candidate.entityId)]; + await writeJSON(storageKeys.watchTombstones, next); + if (candidate.entityKind === 'measurement') { + const measurements = await readMeasurements(); + await writeJSON(storageKeys.watchMeasurements, measurements.filter(item => item.id !== candidate.entityId)); + } else { + const sessions = await readSessions(); + await writeJSON(storageKeys.watchRegulationSessions, sessions.filter(item => item.id !== candidate.entityId)); + } + return 'applied'; +} + +export const watchRepository = { + async getMeasurements(): Promise { + await mutationQueue; + const [items, tombstones] = await Promise.all([readMeasurements(), readTombstones()]); + return items.filter(item => tombstoneFor(tombstones, 'measurement', item.id) === undefined); + }, + + async getRegulationSessions(): Promise { + await mutationQueue; + const [items, tombstones] = await Promise.all([readSessions(), readTombstones()]); + return items.filter(item => tombstoneFor(tombstones, 'regulationSession', item.id) === undefined); + }, + + async deleteMeasurement(id: string, deletedAt = new Date().toISOString()): Promise { + return enqueue(async () => { + const tombstone: WatchTombstoneV1 = {schemaVersion: 1, entityId: id, entityKind: 'measurement', deletedAt, updatedAt: deletedAt}; + await applyTombstone(tombstone); + return tombstone; + }); + }, + + async clearAll(deletedAt = new Date().toISOString()): Promise { + return enqueue(async () => { + const [measurements, sessions] = await Promise.all([readMeasurements(), readSessions()]); + const created = [ + ...measurements.map(item => ({schemaVersion: 1 as const, entityId: item.id, entityKind: 'measurement' as const, deletedAt, updatedAt: deletedAt})), + ...sessions.map(item => ({schemaVersion: 1 as const, entityId: item.id, entityKind: 'regulationSession' as const, deletedAt, updatedAt: deletedAt})), + ]; + await writeJSON(storageKeys.watchTombstones, created); + await Promise.all([writeJSON(storageKeys.watchMeasurements, []), writeJSON(storageKeys.watchRegulationSessions, [])]); + return created; + }); + }, + + async importEnvelope(input: WatchSyncEnvelopeV1 | unknown): Promise { + return enqueue(async () => { + const envelope = parseWatchEnvelope(input); + if (envelope === null) return {eventId: null, status: 'invalid'}; + const processed = await processedEvents(); + if (processed.includes(envelope.eventId)) return {eventId: envelope.eventId, status: 'duplicate'}; + const tombstones = await readTombstones(); + let status: WatchImportStatus; + switch (envelope.kind) { + case 'measurementUpsert': { + const measurement = parseWatchMeasurement(envelope.payload); + status = measurement === null ? 'invalid' : await upsertMeasurement(measurement, tombstones); + break; + } + case 'regulationSessionUpsert': { + const session = parseWatchRegulationSession(envelope.payload); + status = session === null ? 'invalid' : await upsertSession(session, tombstones); + break; + } + case 'feelingUpsert': status = await applyFeeling(envelope.payload, tombstones); break; + case 'tombstone': { + const tombstone = parseWatchTombstone(envelope.payload); + status = tombstone === null ? 'invalid' : await applyTombstone(tombstone); + break; + } + default: status = 'ignored'; + } + // Invalid payloads are intentionally not acknowledged as processed so a fixed retry can win. + if (status !== 'invalid') await markProcessed(envelope.eventId, processed); + return {eventId: envelope.eventId, status}; + }); + }, +}; diff --git a/src/features/watch/watchService.test.ts b/src/features/watch/watchService.test.ts new file mode 100644 index 0000000..c3252d6 --- /dev/null +++ b/src/features/watch/watchService.test.ts @@ -0,0 +1,50 @@ +import fixtures from '../../../shared/watch-sync-fixtures.v1.json'; + +const mockStore = new Map(); +jest.mock('@react-native-async-storage/async-storage', () => ({ + __esModule: true, + default: { + getItem: jest.fn(async (key: string) => mockStore.get(key) ?? null), + setItem: jest.fn(async (key: string, value: string) => { mockStore.set(key, value); }), + }, +})); +jest.mock('react-native-haptika-ppg', () => ({haptikaCardiacMirror: {}})); + +import {WatchSyncCoordinator, type WatchNativeTransport} from './watchService'; + +describe('Watch sync coordinator', () => { + beforeEach(() => mockStore.clear()); + + test('imports, acknowledges, then recognizes a replay as duplicate', async () => { + const acknowledgeWatchEvents = jest.fn(); + const transport: WatchNativeTransport = { + startWatchSession: jest.fn(), + watchConnectionState: jest.fn(async () => ({supported: true, paired: true, watchAppInstalled: true, reachable: false, activationState: 'activated'})), + drainWatchEvents: jest.fn(async () => [JSON.stringify(fixtures.measurementEnvelope)]), + acknowledgeWatchEvents, + sendWatchPreferences: jest.fn(async () => true), + sendWatchCommand: jest.fn(async () => true), + transferWatchEvent: jest.fn(async () => true), + }; + const coordinator = new WatchSyncCoordinator(transport); + expect((await coordinator.syncOnce()).imported).toBe(1); + expect((await coordinator.syncOnce()).duplicateOrStale).toBe(1); + expect(acknowledgeWatchEvents).toHaveBeenCalledWith([fixtures.measurementEnvelope.eventId]); + await expect(coordinator.connectionState()).resolves.toMatchObject({paired: true, watchAppInstalled: true, reachable: false}); + }); + + test('does not acknowledge malformed envelopes', async () => { + const acknowledgeWatchEvents = jest.fn(); + const transport = { + startWatchSession: jest.fn(), + watchConnectionState: jest.fn(async () => ({supported: false, paired: false, watchAppInstalled: false, reachable: false, activationState: 'notActivated'})), + drainWatchEvents: jest.fn(async () => ['not-json']), + acknowledgeWatchEvents, + sendWatchPreferences: jest.fn(async () => false), + sendWatchCommand: jest.fn(async () => false), + transferWatchEvent: jest.fn(async () => false), + } as WatchNativeTransport; + expect((await new WatchSyncCoordinator(transport).syncOnce()).invalid).toBe(1); + expect(acknowledgeWatchEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/watch/watchService.ts b/src/features/watch/watchService.ts new file mode 100644 index 0000000..3510b21 --- /dev/null +++ b/src/features/watch/watchService.ts @@ -0,0 +1,162 @@ +import {NativeModules} from 'react-native'; + +import type { + WatchConnectionState, + WatchPreferencesV1, + WatchSyncEnvelopeV1, +} from './watchTypes'; +import {watchRepository, type WatchImportResult} from './watchRepository'; +import {parseWatchEnvelope} from './watchValidation'; + +export interface WatchNativeTransport { + startWatchSession(): void; + watchConnectionState(): Promise<{ + supported: boolean; + paired: boolean; + watchAppInstalled: boolean; + reachable: boolean; + activationState: string; + latestEventId?: string; + latestEventAt?: string; + }>; + drainWatchEvents(): Promise; + acknowledgeWatchEvents(eventIds: string[]): void; + sendWatchPreferences(json: string): Promise; + sendWatchCommand(json: string): Promise; + transferWatchEvent(json: string): Promise; +} + +export interface WatchSyncBatchResult { + imported: number; + duplicateOrStale: number; + invalid: number; + events: readonly WatchImportResult[]; +} + +type NativeWatchModule = { + start?: () => void; + getConnectionState?: () => Promise>>; + drainEvents?: () => Promise; + acknowledgeEvents?: (ids: string[]) => void; + sendPreferences?: (json: string) => Promise; + sendCommand?: (json: string) => Promise; + transferEvent?: (json: string) => Promise; +}; + +const nativeModule = NativeModules.HaptikaWatchConnectivity as NativeWatchModule | undefined; +const nativeTransport: WatchNativeTransport = { + startWatchSession: () => nativeModule?.start?.(), + watchConnectionState: async () => nativeModule?.getConnectionState?.() ?? { + supported: false, paired: false, watchAppInstalled: false, reachable: false, activationState: 'notActivated', + }, + drainWatchEvents: async () => nativeModule?.drainEvents?.() ?? [], + acknowledgeWatchEvents: ids => nativeModule?.acknowledgeEvents?.(ids), + sendWatchPreferences: async json => nativeModule?.sendPreferences?.(json) ?? false, + sendWatchCommand: async json => nativeModule?.sendCommand?.(json) ?? false, + transferWatchEvent: async json => nativeModule?.transferEvent?.(json) ?? false, +}; + +function parseJSONEnvelope(json: string): WatchSyncEnvelopeV1 | null { + try { + return parseWatchEnvelope(JSON.parse(json)); + } catch { + return null; + } +} + +export class WatchSyncCoordinator { + private timer: ReturnType | null = null; + + constructor(private readonly transport: WatchNativeTransport = nativeTransport) {} + + start(onImported?: () => void, pollMilliseconds = 1_500): void { + this.transport.startWatchSession?.(); + if (this.timer !== null) return; + const poll = (): void => { + this.syncOnce().then(batch => { + if (batch.imported > 0) onImported?.(); + }).catch(() => undefined); + }; + poll(); + this.timer = setInterval(poll, pollMilliseconds); + } + + stop(): void { + if (this.timer !== null) clearInterval(this.timer); + this.timer = null; + } + + async connectionState(): Promise { + const state = await this.transport.watchConnectionState?.() ?? { + supported: false, paired: false, watchAppInstalled: false, reachable: false, activationState: 'notActivated', + }; + const activationState = state.activationState === 'activated' || state.activationState === 'inactive' + ? state.activationState + : 'notActivated'; + return { + supported: state.supported, + paired: state.paired, + watchAppInstalled: state.watchAppInstalled, + reachable: state.reachable, + activationState, + latestEventId: state.latestEventId ?? null, + latestEventAt: state.latestEventAt ?? null, + }; + } + + async syncOnce(): Promise { + const rawEvents = await this.transport.drainWatchEvents?.() ?? []; + const results: WatchImportResult[] = []; + const acknowledgements: string[] = []; + for (const raw of rawEvents) { + const envelope = parseJSONEnvelope(raw); + if (envelope === null) { + results.push({eventId: null, status: 'invalid'}); + continue; + } + const result = await watchRepository.importEnvelope(envelope); + results.push(result); + if (result.eventId !== null && result.status !== 'invalid') acknowledgements.push(result.eventId); + } + if (acknowledgements.length > 0) { + this.transport.acknowledgeWatchEvents?.([...new Set(acknowledgements)]); + } + return { + imported: results.filter(item => item.status === 'applied').length, + duplicateOrStale: results.filter(item => item.status === 'duplicate' || item.status === 'stale').length, + invalid: results.filter(item => item.status === 'invalid').length, + events: results, + }; + } + + async updatePreferences(preferences: WatchPreferencesV1): Promise { + const envelope: WatchSyncEnvelopeV1 = { + schemaVersion: 1, + eventId: `iphone-preferences-${Date.now()}`, + deviceId: 'iphone', + sequence: 0, + sentAt: new Date().toISOString(), + kind: 'preferences', + payload: preferences, + }; + return this.transport.sendWatchPreferences?.(JSON.stringify(envelope)) ?? false; + } + + async sendCommand(payload: Record): Promise { + return this.transport.sendWatchCommand?.(JSON.stringify({ + schemaVersion: 1, + eventId: `iphone-command-${Date.now()}`, + deviceId: 'iphone', + sequence: 0, + sentAt: new Date().toISOString(), + kind: 'preferences', + payload, + })) ?? false; + } + + async transferEvent(envelope: WatchSyncEnvelopeV1): Promise { + return this.transport.transferWatchEvent?.(JSON.stringify(envelope)) ?? false; + } +} + +export const watchSyncCoordinator = new WatchSyncCoordinator(); diff --git a/src/features/watch/watchTypes.ts b/src/features/watch/watchTypes.ts new file mode 100644 index 0000000..614d392 --- /dev/null +++ b/src/features/watch/watchTypes.ts @@ -0,0 +1,126 @@ +import type {FeelingScore} from '../ppg/storage/storageTypes'; + +export type MeasurementSource = 'iphoneCameraPPG' | 'appleWatchOptical'; + +export interface WatchDeviceIdentityV1 { + id: string; + name: string; + model: string | null; + systemVersion: string | null; +} + +export interface WatchHeartRateSampleV1 { + /** Seconds since `startedAt`; deliberately not presented as beat-to-beat data. */ + offsetSeconds: number; + bpm: number; +} + +export interface WatchMeasurementContextV1 { + atRest: boolean; + posture: 'seated' | 'lying' | 'standing' | null; + recentExercise: boolean | null; + recentCaffeine: boolean | null; + recentMeal: boolean | null; +} + +export interface WatchMeasurementFeelingV1 { + score: FeelingScore; + recordedAt: string; +} + +export interface WatchMeasurementSummaryV1 { + schemaVersion: 1; + id: string; + updatedAt: string; + startedAt: string; + endedAt: string; + source: 'appleWatchOptical'; + device: WatchDeviceIdentityV1; + currentBpm: number | null; + averageBpm: number | null; + minimumBpm: number | null; + maximumBpm: number | null; + samples: readonly WatchHeartRateSampleV1[]; + coverage: number; + quality: number; + valid: boolean; + invalidReason: string | null; + context: WatchMeasurementContextV1; + feeling: WatchMeasurementFeelingV1 | null; +} + +export type WatchRegulationSessionState = + | 'inProgress' + | 'awaitingAfter' + | 'completed' + | 'cancelled'; + +export interface WatchRegulationSessionV1 { + schemaVersion: 1; + id: string; + updatedAt: string; + protocolId: string; + beforeMeasurementId: string | null; + afterMeasurementId: string | null; + startedAt: string; + endedAt: string | null; + durationSeconds: number; + state: WatchRegulationSessionState; + workoutUUID: string | null; + feelingBefore: FeelingScore | null; + feelingAfter: FeelingScore | null; +} + +export type WatchSyncKind = + | 'liveHeartRate' + | 'measurementUpsert' + | 'regulationSessionUpsert' + | 'feelingUpsert' + | 'tombstone' + | 'preferences' + | 'ack'; + +export interface WatchSyncEnvelopeV1 { + schemaVersion: 1; + eventId: string; + deviceId: string; + sequence: number; + sentAt: string; + kind: WatchSyncKind; + payload: T; +} + +export interface WatchTombstoneV1 { + schemaVersion: 1; + entityId: string; + entityKind: 'measurement' | 'regulationSession'; + deletedAt: string; + updatedAt: string; +} + +export interface WatchPreferencesV1 { + schemaVersion: 1; + saveMindAndBodyWorkouts: boolean; + protocolCatalogVersion: string; + updatedAt: string; +} + +export interface WatchConnectionState { + supported: boolean; + paired: boolean; + watchAppInstalled: boolean; + reachable: boolean; + activationState: 'notActivated' | 'inactive' | 'activated'; + latestEventId: string | null; + latestEventAt: string | null; +} + +export interface WatchMeasurementComparisonV1 { + beforeMeasurementId: string; + afterMeasurementId: string; + bpm: {before: number; after: number; delta: number}; + quality: {before: number; after: number; delta: number}; + feeling: {before: number; after: number; delta: number} | null; + /** Explicitly absent because public Watch workout APIs do not expose reliable live PRV. */ + prvAvailable: false; +} diff --git a/src/features/watch/watchValidation.ts b/src/features/watch/watchValidation.ts new file mode 100644 index 0000000..0f82936 --- /dev/null +++ b/src/features/watch/watchValidation.ts @@ -0,0 +1,97 @@ +import type { + WatchMeasurementSummaryV1, + WatchRegulationSessionV1, + WatchSyncEnvelopeV1, + WatchTombstoneV1, +} from './watchTypes'; + +type RecordValue = Record; +const record = (value: unknown): value is RecordValue => + typeof value === 'object' && value !== null && !Array.isArray(value); +const finite = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value); +const date = (value: unknown): value is string => + typeof value === 'string' && Number.isFinite(Date.parse(value)); +const nonEmpty = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; +const nullableString = (value: unknown): value is string | null => + value === null || typeof value === 'string'; +const validBpm = (value: unknown): value is number => + finite(value) && value >= 35 && value <= 220; + +export function parseWatchMeasurement( + value: unknown, +): WatchMeasurementSummaryV1 | null { + if (!record(value) || !record(value.device) || !record(value.context)) return null; + const samples = value.samples; + if ( + value.schemaVersion !== 1 || + !nonEmpty(value.id) || + !date(value.updatedAt) || + !date(value.startedAt) || + !date(value.endedAt) || + value.source !== 'appleWatchOptical' || + !nonEmpty(value.device.id) || + !nonEmpty(value.device.name) || + !nullableString(value.device.model) || + !nullableString(value.device.systemVersion) || + !(value.currentBpm === null || validBpm(value.currentBpm)) || + !(value.averageBpm === null || validBpm(value.averageBpm)) || + !(value.minimumBpm === null || validBpm(value.minimumBpm)) || + !(value.maximumBpm === null || validBpm(value.maximumBpm)) || + !Array.isArray(samples) || + !samples.every(sample => + record(sample) && finite(sample.offsetSeconds) && sample.offsetSeconds >= 0 && validBpm(sample.bpm), + ) || + !finite(value.coverage) || value.coverage < 0 || value.coverage > 1 || + !finite(value.quality) || value.quality < 0 || value.quality > 1 || + typeof value.valid !== 'boolean' || + !nullableString(value.invalidReason) || + typeof value.context.atRest !== 'boolean' || + ![null, 'seated', 'lying', 'standing'].includes(value.context.posture as never) || + !(value.context.recentExercise === null || typeof value.context.recentExercise === 'boolean') || + !(value.context.recentCaffeine === null || typeof value.context.recentCaffeine === 'boolean') || + !(value.context.recentMeal === null || typeof value.context.recentMeal === 'boolean') + ) return null; + if (Date.parse(value.endedAt) < Date.parse(value.startedAt)) return null; + if (finite(value.minimumBpm) && finite(value.maximumBpm) && finite(value.averageBpm) && + (value.minimumBpm > value.maximumBpm || value.averageBpm < value.minimumBpm || value.averageBpm > value.maximumBpm)) return null; + let feeling: WatchMeasurementSummaryV1['feeling'] = null; + if (value.feeling !== null) { + if (!record(value.feeling) || ![1, 2, 3, 4, 5].includes(Number(value.feeling.score)) || !date(value.feeling.recordedAt)) return null; + feeling = value.feeling as unknown as WatchMeasurementSummaryV1['feeling']; + } + return {...value, feeling} as unknown as WatchMeasurementSummaryV1; +} + +export function parseWatchRegulationSession( + value: unknown, +): WatchRegulationSessionV1 | null { + if (!record(value)) return null; + if ( + value.schemaVersion !== 1 || !nonEmpty(value.id) || !date(value.updatedAt) || + !nonEmpty(value.protocolId) || !(value.beforeMeasurementId === null || nonEmpty(value.beforeMeasurementId)) || + !(value.afterMeasurementId === null || nonEmpty(value.afterMeasurementId)) || !date(value.startedAt) || + !(value.endedAt === null || date(value.endedAt)) || !finite(value.durationSeconds) || value.durationSeconds < 0 || + !['inProgress', 'awaitingAfter', 'completed', 'cancelled'].includes(String(value.state)) || + !(value.workoutUUID === null || nonEmpty(value.workoutUUID)) || + !(value.feelingBefore === null || [1, 2, 3, 4, 5].includes(Number(value.feelingBefore))) || + !(value.feelingAfter === null || [1, 2, 3, 4, 5].includes(Number(value.feelingAfter))) + ) return null; + return value as unknown as WatchRegulationSessionV1; +} + +export function parseWatchTombstone(value: unknown): WatchTombstoneV1 | null { + if (!record(value) || value.schemaVersion !== 1 || !nonEmpty(value.entityId) || + !['measurement', 'regulationSession'].includes(String(value.entityKind)) || + !date(value.deletedAt) || !date(value.updatedAt)) return null; + return value as unknown as WatchTombstoneV1; +} + +export function parseWatchEnvelope(value: unknown): WatchSyncEnvelopeV1 | null { + if (!record(value) || value.schemaVersion !== 1 || !nonEmpty(value.eventId) || + !nonEmpty(value.deviceId) || !finite(value.sequence) || value.sequence < 0 || !date(value.sentAt) || + !['liveHeartRate', 'measurementUpsert', 'regulationSessionUpsert', 'feelingUpsert', 'tombstone', 'preferences', 'ack'].includes(String(value.kind)) || + !('payload' in value)) return null; + return value as unknown as WatchSyncEnvelopeV1; +} diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..e00c490 --- /dev/null +++ b/src/theme.ts @@ -0,0 +1,31 @@ +export const colors = { + background: '#090A0D', + surface: '#14161B', + surfaceElevated: '#1B1E24', + border: '#2A2E36', + text: '#F5F4EF', + muted: '#A7ABB4', + accent: '#FF6B52', + accentSoft: '#3D211E', + good: '#58D6A4', + medium: '#F4B860', + poor: '#FF7867', + blue: '#79A9FF', + white: '#FFFFFF', +} as const; + +export const spacing = { + xs: 6, + sm: 10, + md: 16, + lg: 24, + xl: 32, + xxl: 48, +} as const; + +export const radii = { + sm: 10, + md: 16, + lg: 24, + pill: 999, +} as const; diff --git a/tsconfig.json b/tsconfig.json index 266ba9c..f09594c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,12 @@ { "extends": "@react-native/typescript-config", "compilerOptions": { - "types": ["jest"] + "resolveJsonModule": true, + "types": ["jest"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true }, "include": ["**/*.ts", "**/*.tsx"], "exclude": ["**/node_modules", "**/Pods"]