Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f4f6f4c71 | |||
| 8666f6eb8f | |||
| dcecd07d56 | |||
| b9930b7378 | |||
| 6b7d4e0728 |
@@ -0,0 +1,2 @@
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
BUNDLE_FORCE_RUBY_PLATFORM: 1
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: '@react-native',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
{"projects":{"default":"haptika-d13d3"}}
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
arrowParens: 'avoid',
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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;
|
||||
@@ -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`.
|
||||
@@ -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'
|
||||
@@ -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é.
|
||||
|
||||
@@ -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 />);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
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>
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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')
|
||||
@@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
presets: ['module:@react-native/babel-preset'],
|
||||
plugins: ['react-native-worklets/plugin'],
|
||||
};
|
||||
@@ -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**.
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
});
|
||||
@@ -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"}}
|
||||
@@ -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"}}
|
||||
@@ -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'});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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() } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||