5 Commits

Author SHA1 Message Date
leon 4f4f6f4c71 feat: iphone app 2026-09-09 10:12:49 +02:00
leon 8666f6eb8f feat: iphone app 2026-09-08 09:07:27 +02:00
leon dcecd07d56 feat: watch design from figma 2026-09-07 16:02:36 +02:00
Sacha b9930b7378 feat: implement Haptika PPG and Watch V1 2026-07-31 16:20:10 +02:00
Sacha 6b7d4e0728 Initial commit 2026-07-10 09:56:47 +02:00
734 changed files with 100191 additions and 58 deletions
+2
View File
@@ -0,0 +1,2 @@
BUNDLE_PATH: "vendor/bundle"
BUNDLE_FORCE_RUBY_PLATFORM: 1
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: '@react-native',
};
+1
View File
@@ -0,0 +1 @@
{"projects":{"default":"haptika-d13d3"}}
+85
View File
@@ -0,0 +1,85 @@
# OSX
#
.DS_Store
# Xcode
#
build/
*.pbxuser
!default.pbxuser
*.mode1v3
!default.mode1v3
*.mode2v3
!default.mode2v3
*.perspectivev3
!default.perspectivev3
xcuserdata
*.xccheckout
*.moved-aside
DerivedData
*.hmap
*.ipa
*.xcuserstate
**/.xcode.env.local
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
*.keystore
!debug.keystore
.kotlin/
# node.js
#
node_modules/
npm-debug.log
yarn-error.log
# fastlane
#
# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
# screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/
**/fastlane/report.xml
**/fastlane/Preview.html
**/fastlane/screenshots
**/fastlane/test_output
# Bundle artifact
*.jsbundle
# Ruby / CocoaPods
**/Pods/
/vendor/bundle/
# Swift Package Manager
**/.build/
**/.swiftpm/
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*
# testing
/coverage
# Yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Firebase / Expo local tooling
/.expo/
.firebase/
*-debug.log
firebase-debug.*.log
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
arrowParens: 'avoid',
singleQuote: true,
trailingComma: 'all',
};
+1
View File
@@ -0,0 +1 @@
{}
+853
View File
@@ -0,0 +1,853 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { ActivityIndicator, StatusBar, StyleSheet, View } from 'react-native';
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
import { colors } from './src/theme';
import { OnboardingScreen } from './src/features/onboarding/OnboardingScreen';
import {
onboardingRepository,
type OnboardingAnswers,
} from './src/features/onboarding/onboardingRepository';
import { CardiacMirrorScreen } from './src/features/cardiacMirror/CardiacMirrorScreen';
import { MyCoherenceScreen } from './src/features/coherence/MyCoherenceScreen';
import { coherenceSessionRepository } from './src/features/coherence/coherenceSessionRepository';
import {
HapticWaveExerciseScreen,
type HapticExerciseSummary,
} from './src/features/cardiacMirror/HapticWaveExerciseScreen';
import {
getRegulationProtocol,
recommendRegulationProtocol,
type HapticRegulationProtocolKind,
} from './src/features/cardiacMirror/regulationProtocols';
import { compareRegulationMeasurements } from './src/features/cardiacMirror/regulationComparison';
import { regulationSessionRepository } from './src/features/cardiacMirror/regulationSessionRepository';
import type {
RegulationComparison,
RegulationSession,
} from './src/features/cardiacMirror/regulationSessionTypes';
import { usePPGCamera } from './src/features/ppg/camera/usePPGCamera';
import { evaluateHealthKitContext } from './src/features/ppg/health/healthContextEvaluator';
import { healthKitService } from './src/features/ppg/health/healthKitService';
import type { HealthKitContext } from './src/features/ppg/health/healthTypes';
import { PPG_ALGORITHM_VERSION } from './src/features/ppg/measurement/measurementConfiguration';
import { usePPGMeasurement } from './src/features/ppg/measurement/usePPGMeasurement';
import { PPGFailureScreen } from './src/features/ppg/screens/PPGFailureScreen';
import { PPGHistoryScreen } from './src/features/ppg/screens/PPGHistoryScreen';
import { PPGMeasurementScreen } from './src/features/ppg/screens/PPGMeasurementScreen';
import { PPGPreparationScreen } from './src/features/ppg/screens/PPGPreparationScreen';
import { PPGResultScreen } from './src/features/ppg/screens/PPGResultScreen';
import { calculatePotentialStress } from './src/features/ppg/stress/potentialStressCalculator';
import type { BaselineCalculationResult } from './src/features/ppg/stress/baselineTypes';
import { baselineRepository } from './src/features/ppg/storage/baselineRepository';
import { acquisitionPreferencesRepository } from './src/features/ppg/storage/acquisitionPreferencesRepository';
import { measurementRepository } from './src/features/ppg/storage/measurementRepository';
import { settingsRepository } from './src/features/ppg/storage/settingsRepository';
import type {
DataRetentionPolicy,
MeasurementSelfReport,
StoredMeasurement,
} from './src/features/ppg/storage/storageTypes';
import {calculateWatchBaseline, type WatchBaselineV1} from './src/features/watch/watchBaseline';
import {watchRepository} from './src/features/watch/watchRepository';
import {watchSyncCoordinator} from './src/features/watch/watchService';
import type {
WatchConnectionState,
WatchMeasurementSummaryV1,
WatchRegulationSessionV1,
WatchSyncEnvelopeV1,
WatchTombstoneV1,
} from './src/features/watch/watchTypes';
type AppView = 'measurement' | 'history' | 'cardiacMirror' | 'hapticExercise' | 'myCoherence';
type FinalizationState = 'idle' | 'saving' | 'failed';
type SelfReportState = 'idle' | 'saving' | 'saved' | 'failed';
type OnboardingStatus = 'loading' | 'required' | 'completed';
const EMPTY_WATCH_CONNECTION: WatchConnectionState = {
supported: false,
paired: false,
watchAppInstalled: false,
reachable: false,
activationState: 'notActivated',
latestEventId: null,
latestEventAt: null,
};
function createMeasurementId(): string {
return `ppg-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function App(): React.JSX.Element {
return (
<SafeAreaProvider>
<StatusBar
barStyle="light-content"
translucent
backgroundColor="transparent"
/>
<SafeAreaView
style={styles.safeArea}
edges={['right', 'bottom', 'left']}>
<HaptikaApp />
</SafeAreaView>
</SafeAreaProvider>
);
}
function HaptikaApp(): React.JSX.Element {
const cameraPermission = usePPGCamera();
const measurement = usePPGMeasurement();
const [onboardingStatus, setOnboardingStatus] =
useState<OnboardingStatus>('loading');
const [onboardingAnswers, setOnboardingAnswers] = useState<
OnboardingAnswers | undefined
>();
const [appView, setAppView] = useState<AppView>('measurement');
const [includeHealthKit, setIncludeHealthKit] = useState(false);
const [sensitiveContact, setSensitiveContact] = useState(true);
const [cameraPosition, setCameraPosition] = useState<'back' | 'front'>(
'front',
);
const [atRestSelection, setAtRestSelection] = useState<boolean | null>(null);
const [history, setHistory] = useState<StoredMeasurement[]>([]);
const [watchHistory, setWatchHistory] = useState<WatchMeasurementSummaryV1[]>([]);
const [watchRegulationSessions, setWatchRegulationSessions] = useState<WatchRegulationSessionV1[]>([]);
const [watchBaseline, setWatchBaseline] = useState<WatchBaselineV1 | null>(null);
const [watchConnection, setWatchConnection] = useState<WatchConnectionState>(EMPTY_WATCH_CONNECTION);
const [saveMindAndBodyWorkouts, setSaveMindAndBodyWorkouts] = useState(true);
const [retentionPolicy, setRetentionPolicy] =
useState<DataRetentionPolicy>('forever');
const [baselineProgress, setBaselineProgress] =
useState<BaselineCalculationResult | null>(null);
const [currentMeasurement, setCurrentMeasurement] =
useState<StoredMeasurement | null>(null);
const [exerciseProtocol, setExerciseProtocol] =
useState<HapticRegulationProtocolKind>('coherence55');
const [pendingProtocolIntent, setPendingProtocolIntent] =
useState<HapticRegulationProtocolKind | null>(null);
const [regulationBeforeMeasurement, setRegulationBeforeMeasurement] =
useState<StoredMeasurement | null>(null);
const [pendingRegulationSession, setPendingRegulationSession] =
useState<RegulationSession | null>(null);
const [pendingCoherenceSessionId, setPendingCoherenceSessionId] = useState<
string | null
>(null);
const [regulationComparison, setRegulationComparison] =
useState<RegulationComparison | null>(null);
const [regulationSessions, setRegulationSessions] = useState<
RegulationSession[]
>([]);
const [selfReportState, setSelfReportState] =
useState<SelfReportState>('idle');
const [finalizationState, setFinalizationState] =
useState<FinalizationState>('idle');
const finalizingResultRef = useRef(false);
const measurementAtRestRef = useRef(false);
const postRegulationMeasurementRef = useRef(false);
const refreshHistory = useCallback(async (): Promise<void> => {
setHistory(await measurementRepository.getAll());
}, []);
const refreshWatchData = useCallback(async (): Promise<void> => {
const [measurements, sessions] = await Promise.all([
watchRepository.getMeasurements(),
watchRepository.getRegulationSessions(),
]);
setWatchHistory(measurements);
setWatchRegulationSessions(sessions);
setWatchBaseline(calculateWatchBaseline(measurements));
setWatchConnection(await watchSyncCoordinator.connectionState());
}, []);
const refreshBaseline =
useCallback(async (): Promise<BaselineCalculationResult> => {
const result = await baselineRepository.calculate({
measurementHourLocal: new Date().getHours(),
cameraPosition,
});
setBaselineProgress(result);
return result;
}, [cameraPosition]);
const refreshRegulationSessions = useCallback(async (): Promise<void> => {
setRegulationSessions(await regulationSessionRepository.getAll());
}, []);
useEffect(() => {
onboardingRepository
.get()
.then(state => {
if (state !== null) {
setOnboardingAnswers(state);
}
setOnboardingStatus(
state?.completed === true ? 'completed' : 'required',
);
})
.catch(() => setOnboardingStatus('required'));
settingsRepository.get().then(settings => {
setIncludeHealthKit(settings.includeHealthKitContext);
setSaveMindAndBodyWorkouts(settings.saveMindAndBodyWorkouts);
watchSyncCoordinator.updatePreferences({
schemaVersion: 1,
saveMindAndBodyWorkouts: settings.saveMindAndBodyWorkouts,
protocolCatalogVersion: '1',
updatedAt: new Date().toISOString(),
});
});
acquisitionPreferencesRepository.get().then(preferences => {
setCameraPosition(preferences.cameraPosition);
setSensitiveContact(preferences.sensitiveContact);
});
refreshHistory();
refreshBaseline();
refreshRegulationSessions();
refreshWatchData();
watchSyncCoordinator.start(refreshWatchData);
measurementRepository.getRetentionPolicy().then(setRetentionPolicy);
const connectionTimer = setInterval(() => {
watchSyncCoordinator.connectionState().then(setWatchConnection).catch(() => undefined);
}, 2_000);
return () => {
clearInterval(connectionTimer);
watchSyncCoordinator.stop();
};
}, [refreshBaseline, refreshHistory, refreshRegulationSessions, refreshWatchData]);
useEffect(() => {
if (measurement.snapshot.value === 'cancelled') {
measurement.reset();
}
}, [measurement, measurement.snapshot.value]);
useEffect(() => {
const result = measurement.snapshot.context.result;
if (
measurement.snapshot.value !== 'completed' ||
result === null ||
finalizingResultRef.current ||
finalizationState !== 'idle' ||
currentMeasurement !== null
) {
return;
}
finalizingResultRef.current = true;
setFinalizationState('saving');
(async (): Promise<void> => {
let healthContext: HealthKitContext | null = null;
let contextConsistency = 0.5;
if (
measurement.snapshot.context.healthPermission === 'requestCompleted'
) {
try {
healthContext = await healthKitService.fetchContext(14);
if (healthContext !== null) {
const evaluation = evaluateHealthKitContext(healthContext);
if (evaluation.hasUsableContext) {
contextConsistency = evaluation.contextConfidence;
}
}
} catch {
healthContext = null;
}
}
const baselineResult = await baselineRepository.calculate({
measurementHourLocal: new Date().getHours(),
cameraPosition,
});
const potentialStress = calculatePotentialStress({
atRest: measurementAtRestRef.current,
bpm: result.bpm.bpm,
estimatedRmssdMs: result.prv.estimatedRmssdMs,
prvReliable: result.prv.reliable,
validIntervalCount: result.prv.validIntervalCount,
signalQuality: result.quality.score,
usableDurationSeconds: result.quality.usableDurationSeconds,
peakConfidence: result.bpm.confidence,
methodAgreementScore: result.quality.methodAgreementScore,
contextConsistency,
baseline: baselineResult.baseline,
});
const record: StoredMeasurement = {
schemaVersion: 1,
algorithmVersion: PPG_ALGORITHM_VERSION,
id: createMeasurementId(),
acquisition: {
cameraPosition,
torchUsed: cameraPosition === 'back',
},
measuredAt: new Date().toISOString(),
atRest: measurementAtRestRef.current,
baselineEligible:
measurementAtRestRef.current && !postRegulationMeasurementRef.current,
cameraResult: result,
potentialStress,
healthKitContext: healthContext,
selfReport: null,
};
await measurementRepository.add(record);
if (
pendingRegulationSession !== null &&
regulationBeforeMeasurement !== null
) {
try {
await regulationSessionRepository.complete(
pendingRegulationSession.id,
record.id,
record.measuredAt,
);
if (pendingCoherenceSessionId !== null) {
await coherenceSessionRepository.attachAfterMeasurement(
pendingCoherenceSessionId,
record.id,
);
}
await refreshRegulationSessions();
} catch {
// The physiological result remains usable even if the local journey
// link cannot be persisted. Never discard a completed measurement.
}
setRegulationComparison(
compareRegulationMeasurements(regulationBeforeMeasurement, record),
);
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
}
await refreshBaseline();
setCurrentMeasurement(record);
if (pendingProtocolIntent !== null && pendingRegulationSession === null) {
setExerciseProtocol(pendingProtocolIntent);
setRegulationBeforeMeasurement(record);
setPendingProtocolIntent(null);
setAppView('hapticExercise');
}
setSelfReportState('idle');
refreshHistory().catch(() => undefined);
})()
.catch(() => {
setFinalizationState('failed');
})
.finally(() => {
finalizingResultRef.current = false;
});
}, [
currentMeasurement,
cameraPosition,
finalizationState,
measurement.snapshot.context.healthPermission,
measurement.snapshot.context.result,
measurement.snapshot.value,
pendingRegulationSession,
pendingCoherenceSessionId,
pendingProtocolIntent,
regulationBeforeMeasurement,
refreshHistory,
refreshBaseline,
refreshRegulationSessions,
]);
const handleHealthKitPreference = useCallback((value: boolean): void => {
setIncludeHealthKit(value);
settingsRepository.setIncludeHealthKitContext(value);
}, []);
const handleSaveMindAndBodyPreference = useCallback((value: boolean): void => {
setSaveMindAndBodyWorkouts(value);
settingsRepository.setSaveMindAndBodyWorkouts(value).catch(() => undefined);
watchSyncCoordinator.updatePreferences({
schemaVersion: 1,
saveMindAndBodyWorkouts: value,
protocolCatalogVersion: '1',
updatedAt: new Date().toISOString(),
});
}, []);
const handleOnboardingHealthKit = useCallback(async (): Promise<boolean> => {
if (!healthKitService.isAvailable()) {
return false;
}
try {
const accepted = await healthKitService.requestReadAuthorization();
if (accepted) {
setIncludeHealthKit(true);
await settingsRepository.setIncludeHealthKitContext(true);
}
return accepted;
} catch {
return false;
}
}, []);
const handleCameraPositionPreference = useCallback(
(value: 'back' | 'front'): void => {
setCameraPosition(value);
acquisitionPreferencesRepository
.set({ cameraPosition: value, sensitiveContact })
.catch(() => undefined);
},
[sensitiveContact],
);
const handleSensitiveContactPreference = useCallback(
(value: boolean): void => {
setSensitiveContact(value);
acquisitionPreferencesRepository
.set({ cameraPosition, sensitiveContact: value })
.catch(() => undefined);
},
[cameraPosition],
);
const startMeasurement = useCallback((atRestOverride?: boolean): void => {
const selectedAtRest = atRestOverride ?? atRestSelection;
if (selectedAtRest === null) return;
setAppView('measurement');
setCurrentMeasurement(null);
setSelfReportState('idle');
setFinalizationState('idle');
finalizingResultRef.current = false;
measurementAtRestRef.current = selectedAtRest;
postRegulationMeasurementRef.current = pendingRegulationSession !== null;
if (pendingRegulationSession === null) {
setRegulationComparison(null);
setRegulationBeforeMeasurement(null);
}
measurement.start({
includeHealthKitContext: includeHealthKit,
sensitiveContact,
requestCameraPermission: cameraPermission.requestCameraPermission,
});
}, [
cameraPermission.requestCameraPermission,
atRestSelection,
includeHealthKit,
measurement,
pendingRegulationSession,
sensitiveContact,
]);
const restartMeasurement = useCallback((): void => {
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(null);
setFinalizationState('idle');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = false;
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setRegulationBeforeMeasurement(null);
setRegulationComparison(null);
}, [measurement]);
const retryMeasurement = useCallback((): void => {
setCurrentMeasurement(null);
finalizingResultRef.current = false;
measurement.retry();
if (measurement.snapshot.context.cameraPermission !== 'granted') {
Promise.resolve().then(() => startMeasurement());
}
}, [measurement, startMeasurement]);
const backToPreparation = useCallback((): void => {
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(null);
setFinalizationState('idle');
setSelfReportState('idle');
setAppView('measurement');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = false;
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setRegulationBeforeMeasurement(null);
setRegulationComparison(null);
}, [measurement]);
const startRegulationJourney = useCallback(
(protocolKind: HapticRegulationProtocolKind): void => {
if (currentMeasurement === null) return;
setExerciseProtocol(protocolKind);
setRegulationBeforeMeasurement(currentMeasurement);
setRegulationComparison(null);
setAppView('hapticExercise');
},
[currentMeasurement],
);
const completeRegulationExercise = useCallback(
(summary: HapticExerciseSummary, coherenceSessionId?: string): void => {
if (regulationBeforeMeasurement === null || !summary.completed) return;
(async (): Promise<void> => {
const created = await regulationSessionRepository.create({
protocolKind: summary.protocolKind,
beforeMeasurementId: regulationBeforeMeasurement.id,
startedAt: summary.startedAt,
});
if (coherenceSessionId !== undefined) {
await coherenceSessionRepository.attachRegulationSession(
coherenceSessionId,
created.id,
);
setPendingCoherenceSessionId(coherenceSessionId);
} else {
setPendingCoherenceSessionId(null);
}
const awaitingAfter =
await regulationSessionRepository.markAwaitingAfter(
created.id,
summary.completedAt,
);
await refreshRegulationSessions();
setPendingRegulationSession(awaitingAfter);
measurement.reset();
setCurrentMeasurement(null);
setAtRestSelection(true);
setFinalizationState('idle');
setSelfReportState('idle');
finalizingResultRef.current = false;
postRegulationMeasurementRef.current = true;
setAppView('measurement');
})().catch(() => {
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
setAppView('measurement');
});
},
[measurement, refreshRegulationSessions, regulationBeforeMeasurement],
);
const saveSelfReport = useCallback(
(report: MeasurementSelfReport): void => {
if (currentMeasurement === null) return;
setSelfReportState('saving');
measurementRepository
.attachSelfReport(currentMeasurement.id, report)
.then(async () => {
const baselineEligible =
currentMeasurement.atRest &&
regulationComparison === null &&
![
report.recentExercise,
report.recentCaffeine,
report.recentMeal,
report.movementDuringMeasurement,
report.phoneUnusuallyWarm,
].some(value => value === true);
setCurrentMeasurement({
...currentMeasurement,
selfReport: report,
baselineEligible,
});
setSelfReportState('saved');
await Promise.all([refreshHistory(), refreshBaseline()]);
})
.catch(() => setSelfReportState('failed'));
},
[currentMeasurement, refreshBaseline, refreshHistory, regulationComparison],
);
const deleteMeasurement = useCallback(
async (measurementId: string): Promise<void> => {
await Promise.all([
measurementRepository.deleteById(measurementId),
regulationSessionRepository.deleteRelatedToMeasurement(measurementId),
coherenceSessionRepository.deleteRelatedToMeasurement(measurementId),
]);
await Promise.all([
refreshHistory(),
refreshBaseline(),
refreshRegulationSessions(),
]);
},
[refreshBaseline, refreshHistory, refreshRegulationSessions],
);
const transferWatchTombstone = useCallback((tombstone: WatchTombstoneV1): void => {
const envelope: WatchSyncEnvelopeV1<WatchTombstoneV1> = {
schemaVersion: 1,
eventId: `iphone-delete-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
deviceId: 'iphone',
sequence: 0,
sentAt: new Date().toISOString(),
kind: 'tombstone',
payload: tombstone,
};
watchSyncCoordinator.transferEvent(envelope);
}, []);
const deleteWatchMeasurement = useCallback(async (measurementId: string): Promise<void> => {
const tombstone = await watchRepository.deleteMeasurement(measurementId);
transferWatchTombstone(tombstone);
await refreshWatchData();
}, [refreshWatchData, transferWatchTombstone]);
const clearMeasurementData = useCallback(async (): Promise<void> => {
const [, , , watchTombstones] = await Promise.all([
measurementRepository.clearAll(),
regulationSessionRepository.clearAll(),
coherenceSessionRepository.clearAll(),
watchRepository.clearAll(),
]);
watchTombstones.forEach(transferWatchTombstone);
setCurrentMeasurement(null);
await Promise.all([
refreshHistory(),
refreshBaseline(),
refreshRegulationSessions(),
refreshWatchData(),
]);
}, [refreshBaseline, refreshHistory, refreshRegulationSessions, refreshWatchData, transferWatchTombstone]);
const changeRetentionPolicy = useCallback(
async (policy: DataRetentionPolicy): Promise<void> => {
await measurementRepository.setRetentionPolicy(policy);
setRetentionPolicy(policy);
await Promise.all([refreshHistory(), refreshBaseline()]);
},
[refreshBaseline, refreshHistory],
);
if (onboardingStatus === 'loading') {
return (
<View style={styles.loadingResult}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
if (onboardingStatus === 'required') {
return (
<OnboardingScreen
initialAnswers={onboardingAnswers}
onRequestHealthKit={handleOnboardingHealthKit}
onComplete={answers => {
setOnboardingAnswers(answers);
setOnboardingStatus('completed');
}}
/>
);
}
if (appView === 'history') {
return (
<PPGHistoryScreen
measurements={history}
watchMeasurements={watchHistory}
baselineProgress={baselineProgress}
watchBaseline={watchBaseline}
regulationSessions={regulationSessions}
watchRegulationSessions={watchRegulationSessions}
watchConnectionState={watchConnection}
saveMindAndBodyWorkouts={saveMindAndBodyWorkouts}
retentionPolicy={retentionPolicy}
onDeleteMeasurement={deleteMeasurement}
onDeleteWatchMeasurement={deleteWatchMeasurement}
onClearAll={clearMeasurementData}
onSaveMindAndBodyWorkoutsChange={handleSaveMindAndBodyPreference}
onRetentionPolicyChange={changeRetentionPolicy}
onBack={() => {
setRegulationBeforeMeasurement(null);
setPendingRegulationSession(null);
setAppView('measurement');
}}
/>
);
}
if (appView === 'cardiacMirror') {
return <CardiacMirrorScreen onBack={() => setAppView('measurement')} />;
}
if (appView === 'hapticExercise') {
return (
<HapticWaveExerciseScreen
initialProtocolKind={exerciseProtocol}
onBack={() => setAppView('measurement')}
onCompleted={completeRegulationExercise}
onRequestStart={protocol => {
if (currentMeasurement === null) {
setPendingProtocolIntent(protocol.kind);
setExerciseProtocol(protocol.kind);
setAtRestSelection(true);
setAppView('measurement');
return true;
}
setExerciseProtocol(protocol.kind);
setRegulationBeforeMeasurement(currentMeasurement);
if (protocol.mode === 'livePPG') {
cameraPermission.requestCameraPermission().then(granted => {
if (granted) setAppView('myCoherence');
});
return true;
}
return false;
}}
onCancelled={() => {
setRegulationBeforeMeasurement(null);
setPendingRegulationSession(null);
setPendingCoherenceSessionId(null);
}}
/>
);
}
if (appView === 'myCoherence') {
return (
<MyCoherenceScreen
cameraPosition={cameraPosition}
beforeMeasurementId={regulationBeforeMeasurement?.id ?? null}
onCompleted={(summary, session) => {
completeRegulationExercise(
{
protocolKind: 'myCoherence',
startedAt: summary.startedAt,
completedAt: summary.completedAt,
elapsedSeconds: summary.elapsedSeconds,
completed: summary.completed,
},
session.id,
);
}}
onCancelled={() => {
setRegulationBeforeMeasurement(null);
setAppView('hapticExercise');
}}
/>
);
}
const state = measurement.snapshot.value;
if (
state === 'preparingCamera' ||
state === 'waitingForFinger' ||
state === 'stabilizing' ||
state === 'measuring' ||
state === 'processing'
) {
return (
<PPGMeasurementScreen
state={state}
latestFrame={measurement.latestFrame}
phaseProgress={measurement.phaseProgress}
isCameraActive={measurement.isCameraActive}
torchEnabled={measurement.torchEnabled}
cameraPosition={cameraPosition}
onCameraStarted={measurement.onCameraStarted}
onFrameUpdate={measurement.onFrameUpdate}
onInterrupted={measurement.onCameraInterrupted}
onCameraError={measurement.onCameraError}
onCancel={measurement.cancel}
/>
);
}
if (state === 'completed') {
if (currentMeasurement === null) {
if (finalizationState === 'failed') {
return (
<PPGFailureScreen
state="error"
message="Le résultat n’a pas pu être enregistré localement. Vous pouvez réessayer sans relancer la caméra."
onRetry={() => setFinalizationState('idle')}
onBack={backToPreparation}
/>
);
}
return (
<View style={styles.loadingResult}>
<ActivityIndicator size="large" color={colors.accent} />
</View>
);
}
return (
<PPGResultScreen
cameraResult={currentMeasurement.cameraResult}
measurementHistory={history}
potentialStress={currentMeasurement.potentialStress}
healthKitContext={currentMeasurement.healthKitContext}
baselineProgress={baselineProgress}
regulationComparison={regulationComparison}
recommendedProtocolKind={recommendRegulationProtocol(
currentMeasurement.potentialStress.index,
)}
selfReportSaved={selfReportState === 'saved'}
selfReportSaving={selfReportState === 'saving'}
selfReportError={selfReportState === 'failed'}
onSaveSelfReport={saveSelfReport}
onStartRegulation={startRegulationJourney}
onRestart={restartMeasurement}
onDone={backToPreparation}
/>
);
}
if (
state === 'invalid' ||
state === 'thermalStop' ||
state === 'interrupted' ||
state === 'error'
) {
return (
<PPGFailureScreen
state={state}
message={
measurement.snapshot.context.failure?.message ??
'Le signal n’a pas permis de produire un résultat fiable.'
}
result={measurement.snapshot.context.result}
showOpenSettings={
measurement.snapshot.context.failure?.code ===
'cameraPermissionDenied'
}
onRetry={retryMeasurement}
onBack={backToPreparation}
/>
);
}
return (
<PPGPreparationScreen
includeHealthKit={includeHealthKit}
baselineProgress={baselineProgress}
postExerciseProtocolTitle={
pendingRegulationSession === null
? null
: getRegulationProtocol(pendingRegulationSession.protocolKind).title
}
onIncludeHealthKitChange={handleHealthKitPreference}
sensitiveContact={sensitiveContact}
onSensitiveContactChange={handleSensitiveContactPreference}
cameraPosition={cameraPosition}
onCameraPositionChange={handleCameraPositionPreference}
atRest={atRestSelection}
onAtRestChange={setAtRestSelection}
onStart={startMeasurement}
onOpenHistory={() => setAppView('history')}
onOpenCardiacMirror={() => setAppView('cardiacMirror')}
onOpenHapticExercise={() => setAppView('hapticExercise')}
isStarting={
state === 'requestingCameraPermission' ||
state === 'requestingHealthPermission'
}
/>
);
}
const styles = StyleSheet.create({
safeArea: {
flex: 1,
backgroundColor: colors.background,
},
loadingResult: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background,
},
});
export default App;
Binary file not shown.
+190
View File
@@ -0,0 +1,190 @@
# Architecture Firestore Haptika
État vérifié le 8 septembre 2026 : accès CLI au projet `haptika-d13d3`
(numéro `167940133580`), base `(default)` Firestore Native en `eur3`.
Mise en place engagée : application iOS enregistrée, SDK natifs Auth/Firestore intégrés
dans Expo, règles et index déployés. Le périmètre livré et les étapes restantes
sont décrits dans `firebase/README.md`. Les sections ci-dessous restent la cible
d'architecture ; elles ne signifient pas que tous les capteurs et calculs sont branchés.
## Organisation retenue
Deux applications, un même compte Firebase Authentication et un historique commun.
L'UID Firebase est la frontière d'accès ; ni l'adresse e-mail ni l'identifiant
matériel ne servent de clé utilisateur. La Watch passe par le compagnon iPhone
avec WatchConnectivity ; son fonctionnement et ses vibrations restent locaux.
L'intégration WatchConnectivity dans le nouveau projet Expo reste à construire.
```text
users/{uid} profil et état du compte
onboarding/current réponses et étape d'onboarding
preferences/general préférences communes
practiceSettings/{practiceId} réglages respiration, ancrage, méditation…
devices/{installationId} installations et état de synchronisation
measurements/{measurementId} résumé d'une acquisition
measurementAnnotations/{measurementId} ressenti et contexte déclaratif
sessions/{sessionId} séance et références avant/après
reminders/{reminderId} intention de rappel/réveil
dailyStats/{dayKey} agrégats reconstruisibles, serveur
baselines/{baselineKey} référence personnelle versionnée, serveur
entitlements/current abonnement vérifié, serveur
tombstones/{entityKey} suppressions anti-résurrection, serveur
protocols/{protocolId}/versions/{version} paramètres publiés, serveur
```
Les sous-collections sont créées à la première écriture ; pas de documents vides
pour simuler des tables. Une mesure ou une séance est un document indépendant.
Aucun tableau d'historique croissant dans `users/{uid}`.
## Contrat des documents
Tous les documents persistés portent `schemaVersion: 1`. Les instants sont des
Firestore Timestamp UTC ; `createdAt` et `updatedAt` sont des timestamps serveur.
`startedAt`, `endedAt`, `recordedAt` décrivent le moment sur l'appareil et sont
validés indépendamment. Les IDs d'événements sont des UUID aléatoires créés hors
ligne puis réutilisés à chaque tentative. Les durées sont en secondes, le rythme
cardiaque en bpm et les intervalles en millisecondes. Une valeur indisponible est
`null`, jamais un zéro inventé.
| Document | Champs métier principaux |
| --- | --- |
| Profil | `firstName`, `birthYear` nullable, `gender` nullable, `locale`, `timeZone` IANA. `accountState` (`active`, `deleting`) est contrôlé par le serveur. E-mail et fournisseurs de connexion restent dans Auth. |
| Onboarding | `stress`, `sleep`, `energy` (`low`, `medium`, `high`), `experience` (`phone`, `watch`), `completedAt`, `flowVersion`. L'étape courante utilise une clé métier stable. |
| Préférences | Préférences de notification et de sauvegarde Santé. Une préférence ne prouve jamais une autorisation iOS ; les permissions sont relues sur l'appareil. |
| Réglages de pratique | `durationSeconds`, paramètres de respiration, intensité, son et écran éteint selon la pratique. Schéma fermé différent par pratique ; pas de dictionnaire arbitraire contenant tous les IDs Figma. |
| Installation | `platform`, `appVersion`, `pairedInstallationId` nullable, `lastSeenAt`, `lastSyncedAt`. Identifiant aléatoire d'installation, pas de numéro de série. L'état « connecté » vient de WatchConnectivity, pas de Firestore. |
| Mesure | `source`, `deviceId`, `startedAt`, `endedAt`, `durationSeconds`, `averageBpm`, `minimumBpm`, `maximumBpm`, `estimatedRmssdMs`, `sdnnMs`, `quality`, `coverage`, `valid`, `invalidReasons`, `algorithmVersion`, `stressResult` nullable. `stressResult` garde statut, confiance, limitations et version de référence utilisée. |
| Annotation | `feeling` entier 1–5 nullable, `posture`, `atRest`, `recentExercise`, `recentCaffeine`, `recentMeal`, `recordedAt`, `revision`. Modification indépendante du résultat acquis. |
| Séance | `practiceId`, `protocolVersion`, `deviceId`, `source`, `startedAt`, `endedAt` nullable, `plannedDurationSeconds`, `actualDurationSeconds`, `status`, `beforeMeasurementId`, `afterMeasurementId`, `feelingBefore`, `feelingAfter`, résumé de cohérence nullable, `revision`. |
| Rappel | `kind`, `localTime` (HH:mm), `weekdays` (1=lundi…7=dimanche), `timeZone`, `enabled`, `targetDeviceId`. Planification réelle par l'OS, avec gestion explicite des changements de fuseau et d'heure d'été. |
| Abonnement | `plan`, `status`, `expiresAt`, `provider`, `verifiedAt`. Écrit uniquement après validation serveur des achats ; choisir le parcours Watch ne donne aucun droit payant. |
Les sources canoniques de mesure sont `iphoneCameraPPG` et `appleWatchOptical`,
déjà utilisées dans le contrat Watch. Un éventuel import HealthKit doit avoir sa
propre source, provenance et méthode. Ne pas confondre PRV estimée par caméra,
mesure optique de BPM et HRV HealthKit ; ne pas remplir des métriques absentes
de la Watch à partir des exemples Figma. Qualité et couverture sont normalisées
entre 0 et 1. Chaque algorithme conserve sa version et son statut de disponibilité.
Les séances utilisent `inProgress`, `awaitingAfter`, `completed`, `cancelled`.
`beforeMeasurementId` et `afterMeasurementId` restent des IDs relatifs au même
utilisateur ; la transition finale valide leur existence et leur provenance.
Une séance sans mesure après ne reçoit pas de comparaison fabriquée.
## Synchronisation et conflits
1. Acquisition et minuteur fonctionnent localement ; aucune écriture par battement
ni par seconde. Résumé à la fin, puis mises à jour métier limitées.
2. La file d'envoi locale est persistante et partitionnée par UID. Les anciennes
données sans propriétaire restent locales jusqu'à une association explicite.
3. La Watch envoie son UUID original ; l'iPhone le conserve en Firestore. Les
renvois créent au plus un résultat. Le résumé d'acquisition devient immuable ;
une annotation utilise son document séparé.
4. Les modifications de séances/annotations utilisent une `revision` monotone
validée en transaction. Une révision périmée provoque relecture et résolution,
jamais un écrasement silencieux basé sur l'horloge de la Watch.
5. L'accusé de réception Watch distingue « enregistré durablement sur l'iPhone »
et « synchronisé dans le cloud ». La disparition du réseau ne perd pas la file.
6. Une suppression passe par une opération serveur idempotente : tombstone
déterministe (`measurement_UUID` ou `session_UUID`), suppression du contenu
et invalidation des agrégats. Toute recréation avec cet ID est refusée.
Les tombstones restent jusqu'à suppression du compte ; pas de TTL qui
permettrait à une vieille Watch hors ligne de ressusciter une mesure.
7. À la déconnexion/changement de compte, isoler caches, listeners et files ; la
Watch doit être réassociée avant d'envoyer au nouvel UID. Un message Watch
ne peut pas choisir arbitrairement l'UID de destination.
L'intégration Expo privilégiera les SDK natifs Firebase compatibles avec le
development build pour Auth et le cache Firestore. La file métier reste nécessaire
pour WatchConnectivity et les transactions qui exigent le réseau. Aucun compte
anonyme automatique tant que le parcours de rattachement/récupération n'est pas
défini ; le mode local peut fonctionner sans connexion.
## Historique, index et coût
| Écran / besoin | Requête prévue | Index composite |
| --- | --- | --- |
| Dernières mesures | `measurements orderBy(startedAt desc) limit(30)` + curseur | Index simple |
| Mesures par appareil/source | `where(source == …) orderBy(startedAt desc)` | `source ASC, startedAt DESC` |
| Historique de pratique | `sessions where(practiceId == …) orderBy(startedAt desc)` | `practiceId ASC, startedAt DESC` |
| Séances terminées par période | `sessions where(status == completed) orderBy(startedAt desc)` + bornes | `status ASC, startedAt DESC` |
| Graphique mensuel | `dailyStats` borné par date et fuseau d'agrégation | Selon requête finale |
Les requêtes partent toujours de `users/{uid}`. Pas de listener sur tout
l'historique et pas de requête globale `collectionGroup` côté utilisateur.
Les cartes d'accueil écoutent seulement les documents nécessaires. Exclure de
l'indexation les maps de résultats et les champs jamais interrogés.
Les agrégats sont une optimisation ultérieure, pas un prérequis du premier
branchement : lire d'abord une période bornée. Lors de leur introduction,
`dailyStats` porte `localDate`, `timeZone`, `aggregationVersion`, `computedAt` et
des métriques séparées par source. La clé inclut la date et une clé de fuseau
stable ; un changement de fuseau invalide/recalcule la vue au lieu de mélanger
des journées. Les baselines sont séparées par source, méthode et version
d'algorithme. Les traitements serveur doivent être idempotents face aux événements
dupliqués et gérer modification/suppression ; un simple incrément par trigger
n'est pas suffisant. Un résultat historique garde la référence utilisée à sa date.
## Données qui restent locales
Les frames, textes, traductions, icônes et valeurs de démonstration Figma restent
dans l'application. Les données démo ne migrent jamais dans l'historique réel.
Les échantillons PPG bruts, vidéos caméra, timelines haute fréquence et données
HealthKit détaillées ne sont pas téléversés par défaut. Si une fonction justifie
ultérieurement leur conservation distante, utiliser un stockage de fichiers privé
avec durée de conservation explicite et une référence dans Firestore, plutôt
qu'une série volumineuse dans chaque document de mesure.
## Politique d'accès à implémenter et tester
- Refus par défaut. Auth obligatoire et `request.auth.uid == uid` pour les
documents privés, avec vérification que le compte est actif.
- Autoriser des collections et des champs nommés explicitement ; pas de règle
générique `users/{uid}/{document=**}` autorisant toute écriture.
- Vérifier types, enums, tailles, plages, timestamps et transitions. Les champs
imbriqués nécessitent aussi leurs propres listes de champs autorisés.
- Le client peut créer son profil avec `accountState: active`, puis ne modifier
que les champs de profil. Il ne peut pas réactiver un compte en suppression.
- Le client crée une acquisition valide au sens du schéma, puis ne peut plus
réécrire ses métriques. Les annotations et réglages sont modifiables selon
leur contrat. « Schéma valide » ne signifie pas mesure certifiée par serveur.
- Abonnements, agrégats, références calculées, tombstones et catalogue de
protocoles : écriture serveur uniquement. Protocoles publiés lisibles selon le
parcours produit ; accès aux données privées strictement individuel.
- Les références avant/après restent sous le même UID. La suppression interdit
toute recréation et corrige les séances qui référencent la mesure supprimée.
- La suppression de compte bloque d'abord les écritures, purge récursivement
les sous-collections et fichiers associés, puis supprime Auth. Effacer le seul
document `users/{uid}` ne supprime pas ses sous-collections.
- Tests Emulator requis : non-connecté refusé, utilisateur A/B isolés, écriture
d'abonnement refusée, champs inconnus refusés, timestamps falsifiés refusés,
mauvaise révision refusée, rejeu idempotent, recréation après suppression
refusée, blocage pendant suppression du compte.
## Ordre d'intégration
1. Enregistrer l'app iOS Expo (`com.haptika.mobile.dev`) dans le projet, configurer
Auth selon le parcours retenu, puis intégrer le SDK natif au development build.
2. Écrire les règles et tests Emulator du contrat minimal : profil, préférences,
mesures, annotations et séances ; valider avant déploiement.
3. Brancher l'état Expo via des repositories métier. Mapper les clés Figma
actuelles vers des noms métier stables. Ne pas synchroniser `values` et
`toggles` tels quels et ne pas importer les faux profils de maquette.
4. Porter le pont WatchConnectivity dans Expo et tester hors ligne, rejeux,
changement de compte et suppression sur simulateurs/appareils adaptés.
5. Ajouter validation d'achat serveur, agrégats et catalogue distant uniquement
lorsque leurs fonctions correspondantes sont branchées.
## Sources techniques
- [Modèle Firestore et sous-collections](https://firebase.google.com/docs/firestore/data-model)
- [Bonnes pratiques et indexation](https://firebase.google.com/docs/firestore/best-practices)
- [Contrôle des champs par les règles](https://firebase.google.com/docs/firestore/security/rules-fields)
- [Persistance hors ligne](https://firebase.google.com/docs/firestore/manage-data/enable-offline)
Contrats existants examinés : `ios/HaptikaWatch/Core/WatchModels.swift`,
`ios/HaptikaWatch/Core/WatchStore.swift`, `src/features/ppg/storage/storageTypes.ts`,
`src/features/ppg/stress/stressTypes.ts`, `src/features/coherence/coherenceTypes.ts`,
`src/features/cardiacMirror/regulationSessionTypes.ts`,
`src/features/onboarding/onboardingRepository.ts`, `mobile/src/state/app-state.tsx`.
+17
View File
@@ -0,0 +1,17 @@
source 'https://rubygems.org'
# You may use http://rbenv.org/ or https://rvm.io/ to install and use this version
ruby ">= 2.6.10"
# Exclude problematic versions of cocoapods and activesupport that causes build failures.
gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1'
gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0'
gem 'xcodeproj', '< 1.26.0'
gem 'concurrent-ruby', '< 1.3.4'
# Ruby 3.4.0 has removed some libraries from the standard library.
gem 'bigdecimal'
gem 'logger'
gem 'benchmark'
gem 'mutex_m'
gem 'nkf'
+234 -58
View File
@@ -1,93 +1,269 @@
# haptika
# Haptika — PPG caméra iPhone
Project Haptika
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.
## Getting started
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.
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
## Faisabilité vérifiée
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
Le prototype est réalisable sur iPhone physique avec des limites importantes :
## Add your files
- 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.
* [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
* [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
Références officielles principales :
```
cd existing_repo
git remote add origin https://gitlab.com/agenceminuit/haptika.git
git branch -M main
git push -uf origin main
- [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
```
## Integrate with your tools
### Couche React Native
* [Set up project integrations](https://gitlab.com/agenceminuit/haptika/-/settings/integrations)
- `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.
## Collaborate with your team
### Couche Swift / Nitro
* [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
* [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
* [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
* [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
* [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
Le package local `packages/react-native-haptika-ppg` contient :
## Test and Deploy
- 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.
Use the built-in continuous integration in GitLab.
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.
* [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
* [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
* [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
* [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
* [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
## 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.
# Editing this README
## Pipeline du signal
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
- 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.
## Suggestions for a good README
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.
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Baseline, indice et confiance
## Name
Choose a self-explaining name for your project.
- 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.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## HealthKit
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
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 :
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
- 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
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Module 7 — Miroir cardiaque
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
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.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
Deux modes sont visibles dans l’interface :
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
- **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.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
La cible SwiftUI watchOS 11 fonctionne aussi hors connexion, puis synchronise
ses résumés et séances avec l’iPhone appairé via WatchConnectivity.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
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 :
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
- Cohérence 5–5, pendant 5 minutes ;
- Expiration 4–6, pendant 3 minutes ;
- SOS tension, pendant environ 1 minute ;
- Micro-ancrage, pendant 37 secondes.
## License
For open source projects, say how it is licensed.
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.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.
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
```
Dans Xcode :
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.
Le projet inclut déjà `NSCameraUsageDescription`, `NSHealthShareUsageDescription`, l’entitlement HealthKit, la protection de fichiers complète et `PrivacyInfo.xcprivacy`.
## 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
```
É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.
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.
### Procédure iPhone physique
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.
## Confidentialité
- [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.
## Checklist App Store
- [ ] 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.
## Limites scientifiques et techniques
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é.
+52
View File
@@ -0,0 +1,52 @@
/**
* @format
*/
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 () => {
await ReactTestRenderer.act(() => {
ReactTestRenderer.create(<App />);
});
});
+119
View File
@@ -0,0 +1,119 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
// The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js
// cliFile = file("../../node_modules/react-native/cli.js")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized".
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The command to run when bundling. By default is 'bundle'
// bundleCommand = "ram-bundle"
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/
def enableProguardInReleaseBuilds = false
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.+`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.haptikaapp"
defaultConfig {
applicationId "com.haptikaapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
+27
View File
@@ -0,0 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="${usesCleartextTraffic}"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,22 @@
package com.haptikaapp
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
class MainActivity : ReactActivity() {
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "HaptikaApp"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate =
DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled)
}
@@ -0,0 +1,27 @@
package com.haptikaapp
import android.app.Application
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactHost
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
class MainApplication : Application(), ReactApplication {
override val reactHost: ReactHost by lazy {
getDefaultReactHost(
context = applicationContext,
packageList =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
},
)
}
override fun onCreate() {
super.onCreate()
loadReactNative(this)
}
}
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">HaptikaApp</string>
</resources>
@@ -0,0 +1,9 @@
<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
</style>
</resources>
+21
View File
@@ -0,0 +1,21 @@
buildscript {
ext {
buildToolsVersion = "36.0.0"
minSdkVersion = 24
compileSdkVersion = 36
targetSdkVersion = 36
ndkVersion = "27.1.12297006"
kotlinVersion = "2.1.20"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
}
}
apply plugin: "com.facebook.react.rootproject"
+44
View File
@@ -0,0 +1,44 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=false
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+98
View File
@@ -0,0 +1,98 @@
@REM Copyright (c) Meta Platforms, Inc. and affiliates.
@REM
@REM This source code is licensed under the MIT license found in the
@REM LICENSE file in the root directory of this source tree.
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+6
View File
@@ -0,0 +1,6 @@
pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") }
plugins { id("com.facebook.react.settings") }
extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() }
rootProject.name = 'HaptikaApp'
include ':app'
includeBuild('../node_modules/@react-native/gradle-plugin')
+4
View File
@@ -0,0 +1,4 @@
{
"name": "HaptikaApp",
"displayName": "HaptikaApp"
}
+4
View File
@@ -0,0 +1,4 @@
module.exports = {
presets: ['module:@react-native/babel-preset'],
plugins: ['react-native-worklets/plugin'],
};
+1884
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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**.
+21
View File
@@ -0,0 +1,21 @@
{
"firestore": {
"rules": "firebase/firestore.rules",
"indexes": "firebase/firestore.indexes.json"
},
"emulators": {
"firestore": {
"port": 8085
},
"ui": {
"enabled": false
},
"singleProjectMode": true
},
"functions": [
{
"source": "firebase/functions",
"codebase": "haptika"
}
]
}
+55
View File
@@ -0,0 +1,55 @@
# Firebase Haptika
Projet `haptika-d13d3`, Firestore `(default)`, région existante `eur3`.
## En service
- Règles et trois index composites déployés : mesures par source, séances par pratique/statut.
- Applications natives iOS et Android `com.haptika.mobile.dev` enregistrées, configuration embarquée dans Expo. Aucun secret administrateur embarqué.
- Auth e-mail/mot de passe (déjà activée dans le projet), création de compte, connexion, réinitialisation, déconnexion.
- Profil, choix d'équipement, fin d'onboarding et préférence écran noir synchronisés. File persistante séparée par UID pour les modifications de ces champs. Les champs non mappés restent locaux.
- Acquisition immutable et séances versionnées : contrats Firestore et repositories prêts, sans import des résultats Figma.
- Mode local sans compte ; aucune migration implicite des anciennes données vers un compte.
`users` et ses sous-collections apparaissent lorsqu'un utilisateur crée son compte ou enregistre des données. Aucun faux utilisateur ni historique n'est prérempli.
## Validation
```sh
npm ci --prefix firebase
npm ci --prefix firebase/functions
npm test --prefix firebase
npm run typecheck --prefix mobile
npm test --prefix mobile
```
Java 17 et Node 22+ sont nécessaires pour cette version de l'émulateur. Les tests utilisent exclusivement `demo-haptika`, port 8085. Dix tests couvrent droits d'accès, schémas, timestamps, révisions, mesures immuables, suppression et rejeux. Les tests mobiles couvrent aussi le mapping excluant les valeurs de maquette.
Un smoke test ponctuel sur le vrai projet a vérifié Auth, création/modification/lecture du profil et refus d'écriture des abonnements. Le compte temporaire et son profil ont été supprimés après le test.
Le development build iOS a compilé et a été installé sur le simulateur iPhone 16 Pro (iOS 18.6). Le lancement, l’écran de compte, le refus d’identifiants invalides et le basculement connexion/création ont été vérifiés dans le simulateur. Android est configuré, mais son build n’a pas été testé. La connexion positive native avec un vrai compte utilisateur reste à effectuer.
## Déploiement
```sh
npx firebase-tools deploy --only firestore --project haptika-d13d3
```
Les fonctions `deleteEntity` et `deleteAccount` sont dans `functions/index.js`. Leur code est préparé ; `deleteEntity` est testé avec l'émulateur. Elles ne sont **pas déployées**, car la facturation du projet est désactivée. Après activation de Blaze par le propriétaire :
```sh
npx firebase-tools deploy --only functions:haptika --project haptika-d13d3
```
Ne pas ouvrir les écritures client sur les tombstones pour contourner cette limite. Les fonctions s'exécutent en `europe-west1`. `deleteAccount` exige une authentification récente, bloque les écritures, purge les sous-collections, révoque les jetons et supprime Auth. Il garde un marqueur minimal `deleting` sans profil pour bloquer les anciens ID tokens. La suppression doit être testée avec Auth Emulator avant d'exposer son bouton dans l'app.
## À brancher ensuite
- Pont WatchConnectivity dans Expo et association au bon compte. La Watch existante est encore liée au compagnon natif historique ; elle ne synchronise pas encore avec ce projet.
- Capteurs réels, historique et graphiques : utiliser les repositories, jamais les valeurs des frames. Le repository de mesure garde l'UUID original. Celui de séance exige une révision attendue ; ces transactions nécessitent le réseau et leur file métier Watch reste à construire.
- Validation des achats et écriture serveur des droits : aucun plan payant n'est accordé par le choix du parcours Watch. Pas de validation d'achat fictive.
- Agrégats et baselines : réservés au serveur, sans calcul déployé pour l'instant.
- Rappels/dispositifs : collections réservées en lecture propriétaire ; écritures fermées jusqu'au branchement de leur contrat et des capacités natives.
- Paramètres avancés : les réglages par pratique ont un contrat minimal, mais les clés de sliders Figma ne sont pas téléversées automatiquement.
La maquette conserve ses exemples de santé et d'abonnement. Ils ne prouvent ni un abonnement réel ni une connexion matérielle. Le nouvel écran de compte affiche le véritable état de synchronisation du profil.
+47
View File
@@ -0,0 +1,47 @@
{
"indexes": [
{
"collectionGroup": "measurements",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "source",
"order": "ASCENDING"
},
{
"fieldPath": "startedAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "sessions",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "practiceId",
"order": "ASCENDING"
},
{
"fieldPath": "startedAt",
"order": "DESCENDING"
}
]
},
{
"collectionGroup": "sessions",
"queryScope": "COLLECTION",
"fields": [
{
"fieldPath": "status",
"order": "ASCENDING"
},
{
"fieldPath": "startedAt",
"order": "DESCENDING"
}
]
}
],
"fieldOverrides": []
}
+94
View File
@@ -0,0 +1,94 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
function owner(uid) { return request.auth != null && request.auth.uid == uid; }
function active(uid) { return owner(uid) && get(/databases/$(database)/documents/users/$(uid)).data.accountState == 'active'; }
function bounded(s, n) { return s is string && s.size() <= n; }
function unit(n) { return n is number && n >= 0 && n <= 1; }
function nullableNumber(n, max) { return n == null || (n is number && n >= 0 && n <= max); }
function feeling(n) { return n == null || (n is int && n >= 1 && n <= 5); }
function source(s) { return s in ['iphoneCameraPPG', 'appleWatchOptical']; }
function stamp(d) {
return d.schemaVersion == 1 && d.updatedAt == request.time &&
(resource == null ? d.createdAt == request.time : d.createdAt == resource.data.createdAt);
}
function revision(d) { return d.revision is int && (resource == null ? d.revision == 1 : d.revision == resource.data.revision + 1); }
function alive(uid, kind, id) { return !exists(/databases/$(database)/documents/users/$(uid)/tombstones/$(kind + '_' + id)); }
function profile(d) {
return d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','accountState','firstName','birthYear','gender','locale','timeZone']) &&
stamp(d) && d.accountState == 'active' && bounded(d.firstName,50) &&
(d.birthYear == null || (d.birthYear is int && d.birthYear >= 1900 && d.birthYear <= request.time.year())) &&
d.gender in [null,'female','male'] && bounded(d.locale,20) && bounded(d.timeZone,80);
}
match /users/{uid} {
allow read: if owner(uid);
allow create: if owner(uid) && profile(request.resource.data);
allow update: if active(uid) && profile(request.resource.data);
match /onboarding/{id} {
allow read: if active(uid);
allow create, update: if active(uid) && id == 'current' && stamp(request.resource.data) &&
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','stress','sleep','energy','experience','completed','flowVersion']) &&
request.resource.data.stress in [null,'low','medium','high'] && request.resource.data.sleep in [null,'low','medium','high'] &&
request.resource.data.energy in [null,'low','medium','high'] && request.resource.data.experience in ['phone','watch'] &&
request.resource.data.completed is bool && request.resource.data.flowVersion == 1;
}
match /preferences/{id} {
allow read: if active(uid);
allow create, update: if active(uid) && id == 'general' && stamp(request.resource.data) &&
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','notificationsEnabled','saveMindAndBodyWorkouts','blackScreenEnabled']) &&
request.resource.data.notificationsEnabled is bool && request.resource.data.saveMindAndBodyWorkouts is bool && request.resource.data.blackScreenEnabled is bool;
}
match /practiceSettings/{id} {
allow read: if active(uid);
allow create, update: if active(uid) && id in ['breathing','anchoring','meditation','sleep','wake','coherence'] && stamp(request.resource.data) &&
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','durationSeconds','intensity','soundEnabled']) &&
request.resource.data.durationSeconds is int && request.resource.data.durationSeconds >= 30 && request.resource.data.durationSeconds <= 7200 &&
unit(request.resource.data.intensity) && request.resource.data.soundEnabled is bool;
}
match /measurements/{id} {
function measurement(d) {
return stamp(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','source','deviceId','startedAt','endedAt','durationSeconds','averageBpm','minimumBpm','maximumBpm','estimatedRmssdMs','sdnnMs','quality','coverage','valid','algorithmVersion']) &&
source(d.source) && bounded(d.deviceId,100) && d.startedAt is timestamp && d.endedAt is timestamp &&
d.startedAt <= d.endedAt && d.endedAt <= request.time && d.durationSeconds is number && d.durationSeconds > 0 && d.durationSeconds <= 7200 &&
nullableNumber(d.averageBpm,300) && nullableNumber(d.minimumBpm,300) && nullableNumber(d.maximumBpm,300) &&
nullableNumber(d.estimatedRmssdMs,5000) && nullableNumber(d.sdnnMs,5000) && unit(d.quality) && unit(d.coverage) && d.valid is bool && bounded(d.algorithmVersion,80);
}
allow read: if active(uid);
allow create: if active(uid) && alive(uid,'measurement',id) && measurement(request.resource.data);
}
match /measurementAnnotations/{id} {
allow read: if active(uid);
allow create, update: if active(uid) && alive(uid,'measurement',id) && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id)) &&
stamp(request.resource.data) && revision(request.resource.data) &&
request.resource.data.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','feeling','posture','atRest','recentExercise','recentCaffeine','recentMeal','recordedAt']) &&
feeling(request.resource.data.feeling) && request.resource.data.posture in [null,'seated','standing','lying','unknown'] &&
request.resource.data.atRest is bool && request.resource.data.recentExercise is bool && request.resource.data.recentCaffeine is bool && request.resource.data.recentMeal is bool &&
request.resource.data.recordedAt is timestamp && request.resource.data.recordedAt <= request.time;
}
match /sessions/{id} {
function refValid(id) { return id == null || (id is string && exists(/databases/$(database)/documents/users/$(uid)/measurements/$(id))); }
function session(d) {
return stamp(d) && revision(d) && d.keys().hasOnly(['schemaVersion','createdAt','updatedAt','revision','practiceId','protocolVersion','deviceId','source','startedAt','endedAt','plannedDurationSeconds','actualDurationSeconds','status','beforeMeasurementId','afterMeasurementId','feelingBefore','feelingAfter']) &&
d.practiceId in ['breathing','anchoring','meditation','sleep','wake','coherence'] && d.protocolVersion is int && d.protocolVersion >= 1 &&
bounded(d.deviceId,100) && source(d.source) && d.startedAt is timestamp && d.startedAt <= request.time &&
(d.endedAt == null || (d.endedAt is timestamp && d.endedAt >= d.startedAt && d.endedAt <= request.time)) &&
d.plannedDurationSeconds is int && d.plannedDurationSeconds > 0 && d.plannedDurationSeconds <= 7200 &&
d.actualDurationSeconds is number && d.actualDurationSeconds >= 0 && d.actualDurationSeconds <= 86400 &&
d.status in ['inProgress','awaitingAfter','completed','cancelled'] &&
(d.status == 'inProgress' || d.endedAt != null) && refValid(d.beforeMeasurementId) && refValid(d.afterMeasurementId) &&
feeling(d.feelingBefore) && feeling(d.feelingAfter);
}
allow read: if active(uid);
allow create: if active(uid) && alive(uid,'session',id) && session(request.resource.data);
allow update: if active(uid) && alive(uid,'session',id) && session(request.resource.data) &&
request.resource.data.diff(resource.data).affectedKeys().hasOnly(['updatedAt','revision','endedAt','actualDurationSeconds','status','afterMeasurementId','feelingBefore','feelingAfter']) &&
(resource.data.status == 'inProgress' || (resource.data.status == 'awaitingAfter' && request.resource.data.status in ['awaitingAfter','completed','cancelled']));
}
// Reserved server-owned collections. Unimplemented client contracts stay closed.
match /{collection}/{id} {
allow read: if active(uid) && collection in ['devices','reminders','dailyStats','baselines','entitlements','tombstones'];
}
}
match /protocols/{id}/versions/{version} { allow read: if request.auth != null && resource.data.published == true; }
}
}
+40
View File
@@ -0,0 +1,40 @@
const {initializeApp}=require('firebase-admin/app');
const {getFirestore,FieldValue}=require('firebase-admin/firestore');
const {getAuth}=require('firebase-admin/auth');
const {onCall,HttpsError}=require('firebase-functions/v2/https');
initializeApp();
const db=getFirestore();
const options={region:'europe-west1',maxInstances:3};
function uidOf(request){if(!request.auth)throw new HttpsError('unauthenticated','Connexion requise.');return request.auth.uid;}
exports.deleteEntity=onCall(options,async request=>{
const uid=uidOf(request),{kind,id}=request.data??{};
if(!['measurement','session'].includes(kind)||typeof id!=='string'||!/^[a-zA-Z0-9-]{1,100}$/.test(id))throw new HttpsError('invalid-argument','Entité invalide.');
const user=db.doc(`users/${uid}`),ref=user.collection(kind==='measurement'?'measurements':'sessions').doc(id);
// Tombstone and deletion are atomic. It remains even if cleanup below retries.
await db.runTransaction(async tx=>{const profile=await tx.get(user);if(profile.data()?.accountState!=='active')throw new HttpsError('failed-precondition','Compte indisponible.');const tombstone=user.collection('tombstones').doc(`${kind}_${id}`);const existing=await tx.get(tombstone);if(!existing.exists)tx.create(tombstone,{schemaVersion:1,entityId:id,entityKind:kind,createdAt:FieldValue.serverTimestamp(),updatedAt:FieldValue.serverTimestamp()});tx.delete(ref);if(kind==='measurement')tx.delete(user.collection('measurementAnnotations').doc(id));});
if(kind==='measurement'){
// Bounded batches; repeated calls finish cleanup after a transient failure.
for(const field of ['beforeMeasurementId','afterMeasurementId']){
let page;
do {page=await user.collection('sessions').where(field,'==',id).limit(200).get();if(page.empty)break;const batch=db.batch();for(const d of page.docs)batch.update(d.ref,{[field]:null,revision:FieldValue.increment(1),updatedAt:FieldValue.serverTimestamp()});await batch.commit();}while(!page.empty);
}
}
// Derived data is disposable. Recompute before exposing it again.
await db.recursiveDelete(user.collection('dailyStats'));
await db.recursiveDelete(user.collection('baselines'));
return {deleted:true};
});
exports.deleteAccount=onCall(options,async request=>{
const uid=uidOf(request);
if(Date.now()/1000-request.auth.token.auth_time>300)throw new HttpsError('failed-precondition','Reconnecte-toi avant de supprimer ton compte.');
const user=db.doc(`users/${uid}`);
await user.set({accountState:'deleting',updatedAt:FieldValue.serverTimestamp()},{merge:true});
// Keep blocking profile until all child collections have been removed.
for(const collection of await user.listCollections())await db.recursiveDelete(collection);
await getAuth().revokeRefreshTokens(uid);
try{await getAuth().deleteUser(uid);}catch(e){if(e.code!=='auth/user-not-found')throw e;}
// Retain a minimal deleting marker to deny old ID-token replay until expiry.
// No profile or health information remains in the marker.
await user.set({schemaVersion:1,accountState:'deleting',updatedAt:FieldValue.serverTimestamp()});
return {deleted:true};
});
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"haptika-server","private":true,"main":"index.js","engines":{"node":"22"},"dependencies":{"firebase-admin":"^13.0.0","firebase-functions":"^6.0.0"}}
+8701
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
{"name":"haptika-firestore","private":true,"scripts":{"test":"firebase emulators:exec --project demo-haptika --only firestore 'node --test tests/*.test.cjs'"},"devDependencies":{"@firebase/rules-unit-testing":"4.0.1","firebase":"11.10.0","firebase-tools":"13.12.0"}}
+39
View File
@@ -0,0 +1,39 @@
const {before, after, test} = require('node:test');
const {readFileSync} = require('node:fs');
const {initializeTestEnvironment, assertSucceeds:ok, assertFails:no} = require('@firebase/rules-unit-testing');
const {doc, setDoc, updateDoc, getDoc, deleteDoc, serverTimestamp, Timestamp} = require('firebase/firestore');
let env, a, b;
const meta=()=>({schemaVersion:1,createdAt:serverTimestamp(),updatedAt:serverTimestamp()});
const profile=()=>({...meta(),accountState:'active',firstName:'Test',birthYear:null,gender:null,locale:'fr',timeZone:'Europe/Paris'});
const measure=()=>({...meta(),source:'appleWatchOptical',deviceId:'watch-1',startedAt:Timestamp.fromMillis(1000),endedAt:Timestamp.fromMillis(61000),durationSeconds:60,averageBpm:70,minimumBpm:65,maximumBpm:80,estimatedRmssdMs:null,sdnnMs:null,quality:0.9,coverage:1,valid:true,algorithmVersion:'1'});
const session=()=>({...meta(),revision:1,practiceId:'breathing',protocolVersion:1,deviceId:'phone-1',source:'iphoneCameraPPG',startedAt:Timestamp.fromMillis(1000),endedAt:null,plannedDurationSeconds:300,actualDurationSeconds:0,status:'inProgress',beforeMeasurementId:null,afterMeasurementId:null,feelingBefore:null,feelingAfter:null});
before(async()=>{env=await initializeTestEnvironment({projectId:'demo-haptika',firestore:{host:'127.0.0.1',port:8085,rules:readFileSync('firestore.rules','utf8')}});a=env.authenticatedContext('a').firestore();b=env.authenticatedContext('b').firestore();await setDoc(doc(a,'users/a'),profile());await setDoc(doc(b,'users/b'),profile());});
after(async()=>env?.cleanup());
test('authentication and ownership',async()=>{await no(getDoc(doc(env.unauthenticatedContext().firestore(),'users/a')));await no(getDoc(doc(b,'users/a')));await no(setDoc(doc(b,'users/a/measurements/cross'),measure()));await ok(getDoc(doc(a,'users/a')));});
test('profile rejects privilege and invalid fields',async()=>{for(const patch of [{plan:'premium'},{birthYear:3000},{accountState:'deleting'}])await no(updateDoc(doc(a,'users/a'),{...patch,updatedAt:serverTimestamp()}));});
test('measurements immutable and validated',async()=>{const ref=doc(a,'users/a/measurements/valid');await ok(setDoc(ref,measure()));await no(updateDoc(ref,{averageBpm:95,updatedAt:serverTimestamp()}));await no(deleteDoc(ref));for(const [id,patch] of [['bad',{quality:2}],['time',{updatedAt:Timestamp.fromMillis(0)}],['raw',{samples:[70]}]])await no(setDoc(doc(a,`users/a/measurements/${id}`),{...measure(),...patch}));});
test('client cannot grant server-owned data',async()=>{for(const c of ['entitlements','baselines','dailyStats','tombstones'])await no(setDoc(doc(a,`users/a/${c}/current`),{plan:'premium'}));await no(setDoc(doc(a,'protocols/breathing/versions/1'),{published:true}));});
test('settings closed schema',async()=>{const p={...meta(),notificationsEnabled:false,saveMindAndBodyWorkouts:false,blackScreenEnabled:true};await ok(setDoc(doc(a,'users/a/preferences/general'),p));await no(setDoc(doc(a,'users/a/preferences/other'),p));await no(setDoc(doc(a,'users/a/practiceSettings/breathing'),{...meta(),durationSeconds:0,intensity:0.5,soundEnabled:false}));});
test('session revision, references and terminal state',async()=>{const ref=doc(a,'users/a/sessions/s1');await ok(setDoc(ref,session()));await no(updateDoc(ref,{revision:3,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:2,afterMeasurementId:'missing',updatedAt:serverTimestamp()}));await ok(updateDoc(ref,{revision:2,status:'completed',endedAt:Timestamp.fromMillis(301000),actualDurationSeconds:300,updatedAt:serverTimestamp()}));await no(updateDoc(ref,{revision:3,status:'inProgress',updatedAt:serverTimestamp()}));});
test('annotations require parent, revision and feeling',async()=>{const v={...meta(),revision:1,feeling:3,posture:'seated',atRest:true,recentExercise:false,recentCaffeine:false,recentMeal:false,recordedAt:Timestamp.fromMillis(61000)};await no(setDoc(doc(a,'users/a/measurementAnnotations/missing'),v));await setDoc(doc(a,'users/a/measurements/annotated'),measure());await ok(setDoc(doc(a,'users/a/measurementAnnotations/annotated'),v));await no(updateDoc(doc(a,'users/a/measurementAnnotations/annotated'),{feeling:6,revision:2,updatedAt:serverTimestamp()}));});
test('tombstones block resurrection',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/a/tombstones/measurement_deleted'),{deletedAt:Timestamp.now()}));await no(setDoc(doc(a,'users/a/measurements/deleted'),measure()));});
test('deleting account is blocked',async()=>{await env.withSecurityRulesDisabled(async c=>setDoc(doc(c.firestore(),'users/deleting'),{...profile(),accountState:'deleting'}));const db=env.authenticatedContext('deleting').firestore();await no(setDoc(doc(db,'users/deleting/measurements/m'),measure()));await no(updateDoc(doc(db,'users/deleting'),{accountState:'active',updatedAt:serverTimestamp()}));});
test('server deletion is idempotent and clears same-user references',async()=>{
process.env.GCLOUD_PROJECT='demo-haptika';process.env.FIRESTORE_EMULATOR_HOST='127.0.0.1:8085';
const {deleteEntity}=require('../functions');
await env.withSecurityRulesDisabled(async c=>{
const db=c.firestore();await setDoc(doc(db,'users/delete-test'),{...profile()});
await setDoc(doc(db,'users/delete-test/measurements/target'),measure());
await setDoc(doc(db,'users/delete-test/measurementAnnotations/target'),{feeling:3});
await setDoc(doc(db,'users/delete-test/sessions/linked'),{...session(),beforeMeasurementId:'target'});
});
const request={auth:{uid:'delete-test',token:{}},data:{kind:'measurement',id:'target'}};
await deleteEntity.run(request);await deleteEntity.run(request);
await env.withSecurityRulesDisabled(async c=>{
const db=c.firestore();require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurements/target'))).exists(),false);
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/measurementAnnotations/target'))).exists(),false);
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/tombstones/measurement_target'))).exists(),true);
require('node:assert/strict').equal((await getDoc(doc(db,'users/delete-test/sessions/linked'))).data().beforeMeasurementId,null);
});
await require('node:assert/strict').rejects(deleteEntity.run({data:{kind:'measurement',id:'target'}}),{code:'unauthenticated'});
});
+9
View File
@@ -0,0 +1,9 @@
/**
* @format
*/
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);
+11
View File
@@ -0,0 +1,11 @@
# This `.xcode.env` file is versioned and is used to source the environment
# used when running script phases inside Xcode.
# To customize your local environment, you can create an `.xcode.env.local`
# file that is not versioned.
# NODE_BINARY variable contains the PATH to the node executable.
#
# Customize the NODE_BINARY variable here.
# For example, to use nvm with brew, add the following line
# . "$(brew --prefix nvm)/nvm.sh" --no-use
export NODE_BINARY=$(command -v node)
+940
View File
@@ -0,0 +1,940 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
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 */; };
87D814A03C8826DC5243F782 /* Inter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */; };
8B283CAD2473B5F13BFB92FD /* WatchBaseline.swift in Sources */ = {isa = PBXBuildFile; fileRef = E742A36D6693717080FD19DB /* WatchBaseline.swift */; };
9125FED2B6DD2C0D898E181F /* OFL.txt in Resources */ = {isa = PBXBuildFile; fileRef = 769A9666C8F131FA0A875FA1 /* OFL.txt */; };
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 = "<group>"; };
0D44511883F77E9921F0A31A /* WatchResourceArbiter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchResourceArbiter.swift; sourceTree = "<group>"; };
111670E33699C47D3A93AC41 /* WatchAppModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchAppModel.swift; sourceTree = "<group>"; };
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 = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HaptikaApp/Info.plist; sourceTree = "<group>"; };
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = PrivacyInfo.xcprivacy; path = HaptikaApp/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HaptikaWatchApp.swift; sourceTree = "<group>"; };
32D728BA3B4A37EEAC4733EB /* WatchModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchModels.swift; sourceTree = "<group>"; };
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 = "<group>"; };
3C621C028FA5CDAE7018D032 /* WatchMeasurementValidator.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchMeasurementValidator.swift; sourceTree = "<group>"; };
45C777EEF16A3B58BB485723 /* WatchCardiacMirrorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCardiacMirrorView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HaptikaApp/AppDelegate.swift; sourceTree = "<group>"; };
769A9666C8F131FA0A875FA1 /* OFL.txt */ = {isa = PBXFileReference; includeInIndex = 1; name = OFL.txt; path = HaptikaWatch/Fonts/OFL.txt; sourceTree = "<group>"; };
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 = "<group>"; };
816CF2099D6657DF684CC745 /* WatchBeforeAfterFlowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBeforeAfterFlowView.swift; sourceTree = "<group>"; };
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = HaptikaApp/LaunchScreen.storyboard; sourceTree = "<group>"; };
8D79C037057C356A5804C171 /* WatchCoherenceView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchCoherenceView.swift; sourceTree = "<group>"; };
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 = "<group>"; };
A845A00D463125569C206227 /* WatchTheme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchTheme.swift; sourceTree = "<group>"; };
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 = "<group>"; };
B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = Inter.ttf; path = HaptikaWatch/Fonts/Inter.ttf; sourceTree = "<group>"; };
BF8BE67FB283769179D55328 /* HaptikaWatch.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = HaptikaWatch.entitlements; sourceTree = "<group>"; };
CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchConnectivityService.swift; sourceTree = "<group>"; };
CE56816B3C44D7F4EDE469C4 /* WatchStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchStore.swift; sourceTree = "<group>"; };
D442ED95A5832B6D76BE3836 /* WatchRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchRootView.swift; sourceTree = "<group>"; };
D5C7116D54CE5D0470079330 /* WatchExercisesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchExercisesView.swift; sourceTree = "<group>"; };
D7825C4ECEEDB6FBFAC4913B /* WatchHistoryView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHistoryView.swift; sourceTree = "<group>"; };
D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHealthMeasurementService.swift; sourceTree = "<group>"; };
E6B35DC921291116A06F055C /* RegulationProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RegulationProtocol.swift; sourceTree = "<group>"; };
E742A36D6693717080FD19DB /* WatchBaseline.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchBaseline.swift; sourceTree = "<group>"; };
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 = "<group>"; };
FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WatchHapticService.swift; sourceTree = "<group>"; };
/* 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;
files = (
0C80B921A6F3F58F76C31292 /* libPods-HaptikaApp.a in Frameworks */,
);
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 = "<group>";
};
13B07FAE1A68108700A75B9A /* HaptikaApp */ = {
isa = PBXGroup;
children = (
13B07FB51A68108700A75B9A /* Images.xcassets */,
761780EC2CA45674006654EE /* AppDelegate.swift */,
13B07FB61A68108700A75B9A /* Info.plist */,
81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */,
13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */,
);
name = HaptikaApp;
sourceTree = "<group>";
};
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
5DCACB8F33CDC322A6C60F78 /* libPods-HaptikaApp.a */,
A545FB6B835260DD2FAABBBD /* watchOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */ = {
isa = PBXGroup;
children = (
ED5549BB07ECF7888A132AE9 /* WatchCoreTests.swift */,
);
name = HaptikaWatchTests;
path = HaptikaWatchTests;
sourceTree = "<group>";
};
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
isa = PBXGroup;
children = (
);
name = Libraries;
sourceTree = "<group>";
};
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
13B07FAE1A68108700A75B9A /* HaptikaApp */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
BBD78D7AC51CEA395F1C20DB /* Pods */,
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */,
47085F82A7AB6010AA4FB195 /* HaptikaWatchTests */,
E900FA2623A3C868FA97EFEE /* HaptikaWatch */,
B4AE4816A2AE68CA9E8E1CB0 /* Inter.ttf */,
769A9666C8F131FA0A875FA1 /* OFL.txt */,
);
indentWidth = 2;
sourceTree = "<group>";
tabWidth = 2;
usesTabs = 0;
};
83CBBA001A601CBA00E9B192 /* Products */ = {
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* HaptikaApp.app */,
9DB449283FD6350D75DDC204 /* Haptika Watch App.app */,
A9FDC3E5AA62750BDCA0CB59 /* HaptikaWatchTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
A378E27FE3D44AD2EEF64FC9 /* HaptikaShared */ = {
isa = PBXGroup;
children = (
9EDE70EB42E24C3B38B51541 /* regulation-protocols.v1.json */,
);
name = HaptikaShared;
path = ../shared;
sourceTree = "<group>";
};
A545FB6B835260DD2FAABBBD /* watchOS */ = {
isa = PBXGroup;
children = (
78E5969515006E0E77675C21 /* Foundation.framework */,
);
name = watchOS;
sourceTree = "<group>";
};
B06321B98A4E85D4005C94CB /* Services */ = {
isa = PBXGroup;
children = (
CDE53A2DF65CFBE48E21F8AF /* WatchConnectivityService.swift */,
FA7A2A9E8A782CA3D7F79124 /* WatchHapticService.swift */,
D9ECA1A2507EBE3D6CBBA66F /* WatchHealthMeasurementService.swift */,
);
name = Services;
path = Services;
sourceTree = "<group>";
};
BBD78D7AC51CEA395F1C20DB /* Pods */ = {
isa = PBXGroup;
children = (
3B4392A12AC88292D35C810B /* Pods-HaptikaApp.debug.xcconfig */,
5709B34CF0A7D63546082F79 /* Pods-HaptikaApp.release.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
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 = "<group>";
};
EB11F95E679D64159FCFDE76 /* App */ = {
isa = PBXGroup;
children = (
2D800E0EB5A1B0DA16064C06 /* HaptikaWatchApp.swift */,
111670E33699C47D3A93AC41 /* WatchAppModel.swift */,
);
name = App;
path = App;
sourceTree = "<group>";
};
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 = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
13B07F861A680F5B00A75B9A /* HaptikaApp */ = {
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "HaptikaApp" */;
buildPhases = (
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
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 */
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1210;
TargetAttributes = {
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" */;
compatibilityVersion = "Xcode 12.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 83CBB9F61A601CBA00E9B192;
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* HaptikaApp */,
AB289AE8AED259F5623A2E42 /* Haptika Watch App */,
EA92A2DC9365C1414126C0B1 /* HaptikaWatchTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
13B07F8E1A680F5B00A75B9A /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
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 */,
87D814A03C8826DC5243F782 /* Inter.ttf in Resources */,
9125FED2B6DD2C0D898E181F /* OFL.txt in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
B8FDE984F342D03CB0CC5583 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"$(SRCROOT)/.xcode.env.local",
"$(SRCROOT)/.xcode.env",
);
name = "Bundle React Native code and images";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"\\\"$WITH_ENVIRONMENT\\\" \\\"$REACT_NATIVE_XCODE\\\"\"\n";
};
00EEFC60759A1932668264C0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
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";
showEnvVarsInLog = 0;
};
C38B50BA6285516D6DCD4F65 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-HaptikaApp-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
E235C05ADACE081382539298 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
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";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
13B07F871A680F5B00A75B9A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
761780ED2CA45674006654EE /* AppDelegate.swift in Sources */,
);
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;
baseConfigurationReference = 3B4392A12AC88292D35C810B /* Pods-HaptikaApp.debug.xcconfig */;
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 = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
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;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5709B34CF0A7D63546082F79 /* Pods-HaptikaApp.release.xcconfig */;
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 = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
"-lc++",
);
PRODUCT_BUNDLE_IDENTIFIER = com.sachagaulin.haptika.r537r56465.dev;
PRODUCT_NAME = HaptikaApp;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
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;
PRODUCT_NAME = "$(TARGET_NAME)";
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 = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_SYMBOLS_PRIVATE_EXTERN = NO;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
OTHER_CFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-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;
};
83CBBA211A601CBA00E9B192 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_CXX_LANGUAGE_STANDARD = "c++20";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = YES;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "";
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
/usr/lib/swift,
"$(inherited)",
);
LIBRARY_SEARCH_PATHS = (
"\"$(SDKROOT)/usr/lib/swift\"",
"\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"",
"\"$(inherited)\"",
);
MTL_ENABLE_DEBUG_INFO = NO;
OTHER_CFLAGS = (
"$(inherited)",
"-DRCT_REMOVE_LEGACY_ARCH=1",
);
OTHER_CPLUSPLUSFLAGS = (
"$(OTHER_CFLAGS)",
"-DFOLLY_NO_CONFIG",
"-DFOLLY_MOBILE=1",
"-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;
PRODUCT_NAME = "$(TARGET_NAME)";
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 */
13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "HaptikaApp" */ = {
isa = XCConfigurationList;
buildConfigurations = (
13B07F941A680F5B00A75B9A /* Debug */,
13B07F951A680F5B00A75B9A /* Release */,
);
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 = (
83CBBA201A601CBA00E9B192 /* Debug */,
83CBBA211A601CBA00E9B192 /* Release */,
);
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 */;
}
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2660"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "EA92A2DC9365C1414126C0B1"
BuildableName = "HaptikaWatchTests.xctest"
BlueprintName = "HaptikaWatchTests"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "AB289AE8AED259F5623A2E42"
BuildableName = "Haptika Watch App.app"
BlueprintName = "Haptika Watch App"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1210"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HaptikaApp.app"
BlueprintName = "HaptikaApp"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
BuildableName = "HaptikaAppTests.xctest"
BlueprintName = "HaptikaAppTests"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HaptikaApp.app"
BlueprintName = "HaptikaApp"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
BuildableName = "HaptikaApp.app"
BlueprintName = "HaptikaApp"
ReferencedContainer = "container:HaptikaApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:HaptikaApp.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+48
View File
@@ -0,0 +1,48 @@
import UIKit
import React
import React_RCTAppDelegate
import ReactAppDependencyProvider
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
var window: UIWindow?
var reactNativeDelegate: ReactNativeDelegate?
var reactNativeFactory: RCTReactNativeFactory?
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
let delegate = ReactNativeDelegate()
let factory = RCTReactNativeFactory(delegate: delegate)
delegate.dependencyProvider = RCTAppDependencyProvider()
reactNativeDelegate = delegate
reactNativeFactory = factory
window = UIWindow(frame: UIScreen.main.bounds)
factory.startReactNative(
withModuleName: "HaptikaApp",
in: window,
launchOptions: launchOptions
)
return true
}
}
class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate {
override func sourceURL(for bridge: RCTBridge) -> URL? {
self.bundleURL()
}
override func bundleURL() -> URL? {
#if DEBUG
RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
#else
Bundle.main.url(forResource: "main", withExtension: "jsbundle")
#endif
}
}
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.default-data-protection</key>
<string>NSFileProtectionComplete</string>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,53 @@
{
"images" : [
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
}
}
+53
View File
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Haptika</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>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.</string>
<key>NSHealthShareUsageDescription</key>
<string>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.</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="HaptikaApp" textAlignment="center" lineBreakMode="middleTruncation" baselineAdjustment="alignBaselines" minimumFontSize="18" translatesAutoresizingMaskIntoConstraints="NO" id="GJd-Yh-RWb">
<rect key="frame" x="0.0" y="202" width="375" height="43"/>
<fontDescription key="fontDescription" type="boldSystem" pointSize="36"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" clipsSubviews="YES" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Powered by React Native" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="9" translatesAutoresizingMaskIntoConstraints="NO" id="MN2-I3-ftu">
<rect key="frame" x="0.0" y="626" width="375" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="bottom" secondItem="MN2-I3-ftu" secondAttribute="bottom" constant="20" id="OZV-Vh-mqD"/>
<constraint firstItem="Bcu-3y-fUS" firstAttribute="centerX" secondItem="GJd-Yh-RWb" secondAttribute="centerX" id="Q3B-4B-g5h"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="centerX" secondItem="Bcu-3y-fUS" secondAttribute="centerX" id="akx-eg-2ui"/>
<constraint firstItem="MN2-I3-ftu" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" id="i1E-0Y-4RG"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="bottom" multiplier="1/3" constant="1" id="moa-c2-u7t"/>
<constraint firstItem="GJd-Yh-RWb" firstAttribute="leading" secondItem="Bcu-3y-fUS" secondAttribute="leading" symbolic="YES" id="x7j-FC-K8j"/>
</constraints>
<viewLayoutGuide key="safeArea" id="Bcu-3y-fUS"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="52.173913043478265" y="375"/>
</scene>
</scenes>
</document>
+39
View File
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
</dict>
</plist>
@@ -0,0 +1,16 @@
import SwiftUI
@main
struct HaptikaWatchApp: App {
@StateObject private var model = WatchAppModel()
init() { WatchTheme.registerFonts() }
var body: some Scene {
WindowGroup {
WatchRootView()
.environmentObject(model)
.task { if !WatchDesignMode.enabled { model.start() } }
}
}
}
+135
View File
@@ -0,0 +1,135 @@
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 var serviceSubscriptions = Set<AnyCancellable>()
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() {
// Relay nested service changes so all Watch screens receive live updates.
for publisher in [store.objectWillChange, measurement.objectWillChange, haptics.objectWillChange, connectivity.objectWillChange] {
publisher.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &serviceSubscriptions)
}
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<Payload: Encodable>(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 }
}
@@ -0,0 +1,20 @@
{
"colors": [
{
"idiom": "universal",
"color": {
"color-space": "srgb",
"components": {
"red": "0.290196",
"green": "0.721569",
"blue": "0.909804",
"alpha": "1.000"
}
}
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,13 @@
{
"images" : [
{
"idiom" : "watch-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchAnchor.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,3 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="24" height="18" viewBox="0 0 24 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Icon" d="M8 18C7.45 18 6.97917 17.8042 6.5875 17.4125C6.19583 17.0208 6 16.55 6 16V2C6 1.45 6.19583 0.979167 6.5875 0.5875C6.97917 0.195833 7.45 0 8 0H16C16.55 0 17.0208 0.195833 17.4125 0.5875C17.8042 0.979167 18 1.45 18 2V16C18 16.55 17.8042 17.0208 17.4125 17.4125C17.0208 17.8042 16.55 18 16 18H8V18M16 16V2V2V2H8V2V2V16V16V16H16V16V16V16M12 5C12.2833 5 12.5208 4.90417 12.7125 4.7125C12.9042 4.52083 13 4.28333 13 4C13 3.71667 12.9042 3.47917 12.7125 3.2875C12.5208 3.09583 12.2833 3 12 3C11.7167 3 11.4792 3.09583 11.2875 3.2875C11.0958 3.47917 11 3.71667 11 4C11 4.28333 11.0958 4.52083 11.2875 4.7125C11.4792 4.90417 11.7167 5 12 5V5M0 12V6H2V12H0V12M3 14V4H5V14H3V14M22 12V6H24V12H22V12M19 14V4H21V14H19V14M8 16V16V16V16V16V16V2V2V2V2V2V2V16V16" fill="#A78BFA"/>
</svg>

After

Width:  |  Height:  |  Size: 955 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchBreath.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16.6667" height="14.1667" viewBox="0 0 16.6667 14.1667" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Margin">
<path id="Icon" d="M7.91667 14.1667C7.22222 14.1667 6.63194 13.9236 6.14583 13.4375C5.65972 12.9514 5.41667 12.3611 5.41667 11.6667H7.08333C7.08333 11.9028 7.16319 12.1007 7.32292 12.2604C7.48264 12.4201 7.68056 12.5 7.91667 12.5C8.15278 12.5 8.35069 12.4201 8.51042 12.2604C8.67014 12.1007 8.75 11.9028 8.75 11.6667C8.75 11.4306 8.67014 11.2326 8.51042 11.0729C8.35069 10.9132 8.15278 10.8333 7.91667 10.8333H0V9.16667H7.91667C8.61111 9.16667 9.20139 9.40972 9.6875 9.89583C10.1736 10.3819 10.4167 10.9722 10.4167 11.6667C10.4167 12.3611 10.1736 12.9514 9.6875 13.4375C9.20139 13.9236 8.61111 14.1667 7.91667 14.1667V14.1667M0 5.83333V4.16667H11.25C11.6111 4.16667 11.9097 4.04861 12.1458 3.8125C12.3819 3.57639 12.5 3.27778 12.5 2.91667C12.5 2.55556 12.3819 2.25694 12.1458 2.02083C11.9097 1.78472 11.6111 1.66667 11.25 1.66667C10.8889 1.66667 10.5903 1.78472 10.3542 2.02083C10.1181 2.25694 10 2.55556 10 2.91667H8.33333C8.33333 2.09722 8.61458 1.40625 9.17708 0.84375C9.73958 0.28125 10.4306 0 11.25 0C12.0694 0 12.7604 0.28125 13.3229 0.84375C13.8854 1.40625 14.1667 2.09722 14.1667 2.91667C14.1667 3.73611 13.8854 4.42708 13.3229 4.98958C12.7604 5.55208 12.0694 5.83333 11.25 5.83333H0V5.83333M13.75 12.5V10.8333C14.1111 10.8333 14.4097 10.7153 14.6458 10.4792C14.8819 10.2431 15 9.94444 15 9.58333C15 9.22222 14.8819 8.92361 14.6458 8.6875C14.4097 8.45139 14.1111 8.33333 13.75 8.33333H0V6.66667H13.75C14.5694 6.66667 15.2604 6.94792 15.8229 7.51042C16.3854 8.07292 16.6667 8.76389 16.6667 9.58333C16.6667 10.4028 16.3854 11.0938 15.8229 11.6562C15.2604 12.2188 14.5694 12.5 13.75 12.5V12.5" fill="#4AB8E8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchChevron.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="4.93333" height="8" viewBox="0 0 4.93333 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M3.06667 4L0 0.933333L0.933333 0L4.93333 4L0.933333 8L0 7.06667L3.06667 4V4" fill="#3E484E"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 318 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchHeart.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="16.6667" height="15.2917" viewBox="0 0 16.6667 15.2917" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M8.33333 15.2917L7.125 14.2083C5.72222 12.9444 4.5625 11.8542 3.64583 10.9375C2.72917 10.0208 2 9.19792 1.45833 8.46875C0.916667 7.73958 0.538194 7.06944 0.322917 6.45833C0.107639 5.84722 0 5.22222 0 4.58333C0 3.27778 0.4375 2.1875 1.3125 1.3125C2.1875 0.4375 3.27778 0 4.58333 0C5.30556 0 5.99306 0.152778 6.64583 0.458333C7.29861 0.763889 7.86111 1.19444 8.33333 1.75C8.80556 1.19444 9.36806 0.763889 10.0208 0.458333C10.6736 0.152778 11.3611 0 12.0833 0C13.3889 0 14.4792 0.4375 15.3542 1.3125C16.2292 2.1875 16.6667 3.27778 16.6667 4.58333C16.6667 5.22222 16.559 5.84722 16.3438 6.45833C16.1285 7.06944 15.75 7.73958 15.2083 8.46875C14.6667 9.19792 13.9375 10.0208 13.0208 10.9375C12.1042 11.8542 10.9444 12.9444 9.54167 14.2083L8.33333 15.2917V15.2917" fill="#4AB8E8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1011 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchMeditation.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="19" height="17" viewBox="0 0 19 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Margin">
<path id="Icon" d="M4.01111 17C3.48333 17 3.03472 16.8141 2.66528 16.4422C2.29583 16.0703 2.11111 15.6187 2.11111 15.0875C2.11111 14.7156 2.21667 14.3659 2.42778 14.0383C2.63889 13.7107 2.92037 13.476 3.27222 13.3344L7.38889 11.6875V9.29688C6.43889 10.4125 5.33495 11.2669 4.07708 11.8602C2.81921 12.4534 1.46019 12.75 0 12.75V10.625C1.1963 10.625 2.28264 10.3771 3.25903 9.88125C4.23542 9.38542 5.11944 8.67708 5.91111 7.75625L7.33611 6.05625C7.54722 5.80833 7.79352 5.6224 8.075 5.49844C8.35648 5.37448 8.65556 5.3125 8.97222 5.3125H10.0278C10.3444 5.3125 10.6435 5.37448 10.925 5.49844C11.2065 5.6224 11.4528 5.80833 11.6639 6.05625L13.0889 7.75625C13.8806 8.67708 14.7646 9.38542 15.741 9.88125C16.7174 10.3771 17.8037 10.625 19 10.625V12.75C17.5398 12.75 16.1808 12.4534 14.9229 11.8602C13.665 11.2669 12.5611 10.4125 11.6111 9.29688V11.6875L15.7278 13.3344C16.0796 13.476 16.3611 13.7107 16.5722 14.0383C16.7833 14.3659 16.8889 14.7156 16.8889 15.0875C16.8889 15.6187 16.7042 16.0703 16.3347 16.4422C15.9653 16.8141 15.5167 17 14.9889 17H7.38889V16.4687C7.38889 16.0083 7.53843 15.6276 7.8375 15.3266C8.13657 15.0255 8.51481 14.875 8.97222 14.875H12.1389C12.2972 14.875 12.4248 14.8263 12.5215 14.7289C12.6183 14.6315 12.6667 14.5031 12.6667 14.3438C12.6667 14.1844 12.6183 14.056 12.5215 13.9586C12.4248 13.8612 12.2972 13.8125 12.1389 13.8125H8.97222C8.23333 13.8125 7.6088 14.0693 7.09861 14.5828C6.58843 15.0964 6.33333 15.725 6.33333 16.4687V17H4.01111V17M9.5 4.25C8.91944 4.25 8.42245 4.04193 8.00903 3.62578C7.5956 3.20964 7.38889 2.70938 7.38889 2.125C7.38889 1.54063 7.5956 1.04036 8.00903 0.624219C8.42245 0.208073 8.91944 0 9.5 0C10.0806 0 10.5775 0.208073 10.991 0.624219C11.4044 1.04036 11.6111 1.54063 11.6111 2.125C11.6111 2.70938 11.4044 3.20964 10.991 3.62578C10.5775 4.04193 10.0806 4.25 9.5 4.25V4.25" fill="#E94697"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchPrepare.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="17" height="14" viewBox="0 0 17 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M1.7 14C1.2325 14 0.832292 13.8286 0.499375 13.4859C0.166458 13.1432 0 12.7313 0 12.25V7.875H4.56875L6.035 10.8938C6.10583 11.0542 6.21208 11.1745 6.35375 11.2547C6.49542 11.3349 6.64417 11.375 6.8 11.375C6.95583 11.375 7.10458 11.3349 7.24625 11.2547C7.38792 11.1745 7.49417 11.0542 7.565 10.8938L10.2 5.46875L11.135 7.39375C11.2058 7.55417 11.3121 7.67448 11.4537 7.75469C11.5954 7.8349 11.7442 7.875 11.9 7.875H17V12.25C17 12.7313 16.8335 13.1432 16.5006 13.4859C16.1677 13.8286 15.7675 14 15.3 14H1.7V14M0 6.125V1.75C0 1.26875 0.166458 0.856771 0.499375 0.514063C0.832292 0.171354 1.2325 0 1.7 0H15.3C15.7675 0 16.1677 0.171354 16.5006 0.514063C16.8335 0.856771 17 1.26875 17 1.75V6.125H12.4312L10.965 3.10625C10.8942 2.94583 10.7879 2.83281 10.6462 2.76719C10.5046 2.70156 10.3558 2.66875 10.2 2.66875C10.0442 2.66875 9.89542 2.70156 9.75375 2.76719C9.61208 2.83281 9.50583 2.94583 9.435 3.10625L6.8 8.53125L5.865 6.60625C5.79417 6.44583 5.68792 6.32552 5.54625 6.24531C5.40458 6.1651 5.25583 6.125 5.1 6.125H0V6.125" fill="#73D1FF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchSOS.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20.1833" height="21" viewBox="0 0 20.1833 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M8.05 21V14.0583L2.04167 17.5292L0 14L6.00833 10.5L0 7.02917L2.04167 3.5L8.05 6.97083V0H12.1333V6.97083L18.1417 3.5L20.1833 7.02917L14.175 10.5L20.1833 14L18.1417 17.5292L12.1333 14.0583V21H8.05V21" fill="#F43F5E"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 442 B

@@ -0,0 +1,15 @@
{
"images": [
{
"filename": "WatchStop.svg",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
},
"properties": {
"preserves-vector-representation": true
}
}
@@ -0,0 +1,5 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Container">
<path id="Icon" d="M6 14H14V6H6V14V14M10 20C8.61667 20 7.31667 19.7375 6.1 19.2125C4.88333 18.6875 3.825 17.975 2.925 17.075C2.025 16.175 1.3125 15.1167 0.7875 13.9C0.2625 12.6833 0 11.3833 0 10C0 8.61667 0.2625 7.31667 0.7875 6.1C1.3125 4.88333 2.025 3.825 2.925 2.925C3.825 2.025 4.88333 1.3125 6.1 0.7875C7.31667 0.2625 8.61667 0 10 0C11.3833 0 12.6833 0.2625 13.9 0.7875C15.1167 1.3125 16.175 2.025 17.075 2.925C17.975 3.825 18.6875 4.88333 19.2125 6.1C19.7375 7.31667 20 8.61667 20 10C20 11.3833 19.7375 12.6833 19.2125 13.9C18.6875 15.1167 17.975 16.175 17.075 17.075C16.175 17.975 15.1167 18.6875 13.9 19.2125C12.6833 19.7375 11.3833 20 10 20V20" fill="#FFB4AB" fill-opacity="0.8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 887 B

@@ -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
}
}
+72
View File
@@ -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"
}
}
}
@@ -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
)
}
}
+377
View File
@@ -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
}
}
@@ -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
}
}
+134
View File
@@ -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<UUID>) {
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)
}
}
}
@@ -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<Payload: Encodable>(
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<Payload: Decodable>(
_ 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
}
}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.healthkit</key>
<true/>
</dict>
</plist>
+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Haptika</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSHealthShareUsageDescription</key>
<string>Haptika lit votre fréquence cardiaque pendant une mesure ou une séance guidée afin d’afficher votre évolution en direct.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Haptika peut enregistrer dans Santé les séances guidées Corps et esprit que vous terminez, si vous activez cette option.</string>
<key>WKApplication</key>
<true/>
<key>WKBackgroundModes</key>
<array>
<string>workout-processing</string>
</array>
<key>WKCompanionAppBundleIdentifier</key>
<string>com.sachagaulin.haptika.r537r56465.dev</string>
</dict>
</plist>
@@ -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<UUID>) -> 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()
}
}
}
@@ -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<Void, Never>?
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)
}
}
@@ -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<Void, Never>?
private var finishTask: Task<Void, Never>?
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<HKSampleType>
) {
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())
}
}
}

Some files were not shown because too many files have changed in this diff Show More