@healthcloudai/hc-cameravitals-connector 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # @healthcloudai/hc-cameravitals-connector
2
+
3
+ Camera-based vital signs measurement for React Native (Expo). Measure heart rate, HRV, respiratory rate, and SpO2 using the device camera — with a single `<CameraVitalsScan>` component that works on iOS, Android, and Web.
4
+
5
+ ```tsx
6
+ import {
7
+ CameraVitalsScan,
8
+ CameraVitalsThemeProvider,
9
+ } from "@healthcloudai/hc-cameravitals-connector";
10
+
11
+ export default function App() {
12
+ return (
13
+ <CameraVitalsThemeProvider>
14
+ <CameraVitalsScan
15
+ apiKey="your-api-key"
16
+ onResult={(result) => console.log("Vitals:", result)}
17
+ />
18
+ </CameraVitalsThemeProvider>
19
+ );
20
+ }
21
+ ```
22
+
23
+ ## Features
24
+
25
+ - **Zero config** — Expo config plugin handles ALL native setup
26
+ - **Single component** — `<CameraVitalsScan>` works on iOS, Android, and Web
27
+ - **Vendor-transparent** — Public API uses generic names (`CameraVitalsConfig`, `CameraVitalsResult`); the underlying SDK is an implementation detail
28
+ - **Headless hook** — `useCameraVitals()` gives you full control over measurement lifecycle
29
+ - **Themable** — `CameraVitalsThemeProvider` + design tokens for complete visual customization
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ npm install @healthcloudai/hc-cameravitals-connector
35
+ ```
36
+
37
+ ### Expo Config Plugin
38
+
39
+ Add the plugin to your `app.json`:
40
+
41
+ ```json
42
+ {
43
+ "expo": {
44
+ "plugins": [
45
+ "@healthcloudai/hc-cameravitals-connector"
46
+ ]
47
+ }
48
+ }
49
+ ```
50
+
51
+ This automatically:
52
+ - Adds camera permissions (iOS + Android)
53
+ - Configures the Circadify SDK dependency (Android Maven repo + Gradle dep)
54
+ - Adds linker flags and SPM package references (iOS)
55
+ - Injects RocketSim support for iOS Simulator (debug builds)
56
+
57
+ After adding the plugin, rebuild your native apps:
58
+
59
+ ```bash
60
+ npx expo prebuild --clean
61
+ npx expo run:ios # or run:android
62
+ ```
63
+
64
+ ### Web
65
+
66
+ Web measurement requires `@circadify/web-sdk` (optional peer dependency):
67
+
68
+ ```bash
69
+ npm install @circadify/web-sdk
70
+ ```
71
+
72
+ > **Note**: `@circadify/web-sdk` is hosted on GitHub Packages. Configure a `.npmrc` with your GitHub token:
73
+ > ```
74
+ > @circadify:registry=https://npm.pkg.github.com
75
+ > //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
76
+ > ```
77
+
78
+ ## API
79
+
80
+ ### Components
81
+
82
+ #### `<CameraVitalsScan>`
83
+
84
+ All-in-one scan component with four screens: idle → scanning → results → error.
85
+
86
+ | Prop | Type | Required | Description |
87
+ |------|------|----------|-------------|
88
+ | `apiKey` | `string` | Yes | API key for the vital signs SDK |
89
+ | `onResult` | `(result: CameraVitalsResult) => void` | No | Called on successful measurement |
90
+ | `onError` | `(error: Error) => void` | No | Called on fatal error |
91
+
92
+ #### `<CameraVitalsPreview>`
93
+
94
+ Low-level camera preview. Renders the native viewfinder on iOS/Android.
95
+
96
+ #### `<CameraVitalsThemeProvider>`
97
+
98
+ Theme context provider. Wrap your app to customize colors, spacing, radii, fonts, and dimensions.
99
+
100
+ ```tsx
101
+ import {
102
+ CameraVitalsThemeProvider,
103
+ DEFAULT_THEME,
104
+ mergeCameraVitalsTheme,
105
+ } from "@healthcloudai/hc-cameravitals-connector";
106
+
107
+ const myTheme = mergeCameraVitalsTheme(DEFAULT_THEME, {
108
+ colors: { accent: "#FF6B35" },
109
+ });
110
+
111
+ function App() {
112
+ return (
113
+ <CameraVitalsThemeProvider value={myTheme}>
114
+ <CameraVitalsScan apiKey="..." />
115
+ </CameraVitalsThemeProvider>
116
+ );
117
+ }
118
+ ```
119
+
120
+ ### Hooks
121
+
122
+ #### `useCameraVitals()`
123
+
124
+ Headless hook. Returns a `CameraVitalsAPI` object:
125
+
126
+ ```ts
127
+ const {
128
+ start, // (config, options?) => Promise<CameraVitalsResult>
129
+ stop, // () => Promise<void>
130
+ progress, // ProgressUpdate | null
131
+ quality, // QualityWarning[]
132
+ isScanning, // boolean
133
+ result, // CameraVitalsResult | null
134
+ error, // CameraVitalsError | null
135
+ } = useCameraVitals();
136
+ ```
137
+
138
+ ### Types
139
+
140
+ | Type | Description |
141
+ |------|-------------|
142
+ | `CameraVitalsConfig` | `{ apiKey, demographics? }` |
143
+ | `CameraVitalsResult` | `{ heartRate, hrv?, respiratoryRate?, spo2?, systolicBp?, diastolicBp?, confidence? }` |
144
+ | `CameraVitalsError` | `{ code, message }` |
145
+ | `Demographics` | `{ age?, sex?: "male" \| "female", fitzpatrick?: 1-6 }` |
146
+ | `MeasurementOptions` | `{ demographics?, onProgress?, onQualityWarning? }` |
147
+ | `ProgressUpdate` | `{ percent, phase, elapsedSeconds }` |
148
+ | `QualityWarning` | `{ type, message, severity }` |
149
+ | `CameraVitalsAPI` | Return type of `useCameraVitals()` |
150
+
151
+ ## Requirements
152
+
153
+ | Platform | Minimum Version |
154
+ |----------|----------------|
155
+ | iOS | 17.0+ |
156
+ | Android | API 24+ (Android 7.0) |
157
+ | Expo SDK | 52+ |
158
+ | React Native | 0.72+ |
159
+ | React | 18–19 |
160
+
161
+ ## License
162
+
163
+ MIT
@@ -0,0 +1,37 @@
1
+ apply plugin: 'com.android.library'
2
+ apply plugin: 'kotlin-android'
3
+
4
+ group = "expo.modules.circadify"
5
+ version = "0.1.0"
6
+
7
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
8
+ apply from: expoModulesCorePlugin
9
+ applyKotlinExpoModulesCorePlugin()
10
+ useCoreDependencies()
11
+ useDefaultAndroidSdkVersions()
12
+ useExpoPublishing()
13
+
14
+ android {
15
+ namespace "expo.modules.circadify"
16
+ compileSdk rootProject.ext.compileSdkVersion
17
+ defaultConfig {
18
+ minSdk rootProject.ext.minSdkVersion
19
+ versionCode 1
20
+ versionName "0.1.0"
21
+ }
22
+
23
+ compileOptions {
24
+ sourceCompatibility JavaVersion.VERSION_17
25
+ targetCompatibility JavaVersion.VERSION_17
26
+ }
27
+
28
+ kotlinOptions {
29
+ jvmTarget = "17"
30
+ }
31
+ }
32
+
33
+ dependencies {
34
+ implementation("com.circadify:circadify-android-sdk:0.1.1")
35
+ implementation("androidx.camera:camera-view:1.3.4")
36
+ implementation("androidx.camera:camera-lifecycle:1.3.4")
37
+ }
@@ -0,0 +1,202 @@
1
+ package expo.modules.circadify
2
+
3
+ import android.Manifest
4
+ import android.content.pm.PackageManager
5
+ import android.util.Log
6
+ import androidx.camera.view.PreviewView
7
+ import androidx.core.content.ContextCompat
8
+ import com.circadify.sdk.CircadifyCallbacks
9
+ import com.circadify.sdk.CircadifyConfig
10
+ import com.circadify.sdk.CircadifyError
11
+ import com.circadify.sdk.CircadifySDK
12
+ import com.circadify.sdk.Demographics
13
+ import com.circadify.sdk.MeasurementOptions
14
+ import com.circadify.sdk.ProgressEvent
15
+ import com.circadify.sdk.QualityState
16
+ import com.circadify.sdk.QualityWarning
17
+ import com.circadify.sdk.Sex
18
+ import expo.modules.kotlin.Promise
19
+ import expo.modules.kotlin.modules.Module
20
+ import expo.modules.kotlin.modules.ModuleDefinition
21
+ import kotlinx.coroutines.CoroutineScope
22
+ import kotlinx.coroutines.Dispatchers
23
+ import kotlinx.coroutines.launch
24
+
25
+ /**
26
+ * Expo native module wrapping the Circadify Android SDK.
27
+ *
28
+ * Usage from JS:
29
+ * const result = await CircadifyModule.measureVitals("ck_live_...", {});
30
+ */
31
+ class CircadifyModule : Module() {
32
+ companion object {
33
+ private const val TAG = "CircadifyModule"
34
+
35
+ /** The currently mounted [PreviewView], set by [CircadifyPreviewView]. */
36
+ var currentPreview: PreviewView? = null
37
+ }
38
+
39
+ private var sdk: CircadifySDK? = null
40
+ private val scope = CoroutineScope(Dispatchers.Main)
41
+
42
+ override fun definition() = ModuleDefinition {
43
+ Name("CircadifyModule")
44
+
45
+ View(CircadifyPreviewView::class) { }
46
+
47
+ Events("progress", "qualityWarning", "qualityState")
48
+
49
+ AsyncFunction("measureVitals") { apiKey: String, demographicsMap: Map<String, Any>?, promise: Promise ->
50
+ scope.launch {
51
+ try {
52
+ val activity = appContext.currentActivity
53
+ if (activity == null) {
54
+ promise.reject("NO_ACTIVITY", "No current activity available", null)
55
+ return@launch
56
+ }
57
+
58
+ if (!hasCameraPermission()) {
59
+ promise.reject(
60
+ "CAMERA_PERMISSION_DENIED",
61
+ "Camera permission is required for vital sign measurement",
62
+ null
63
+ )
64
+ return@launch
65
+ }
66
+
67
+ val previewForSdk = currentPreview
68
+ Log.d(TAG, "measureVitals: currentPreview=${previewForSdk}, " +
69
+ "surfaceProvider=${previewForSdk?.surfaceProvider}, " +
70
+ "implementationMode=${previewForSdk?.implementationMode}")
71
+
72
+ val callbacks = CircadifyCallbacks(
73
+ onProgress = { event: ProgressEvent ->
74
+ Log.d(TAG, "Progress: ${event.percent}% phase=${event.phase}")
75
+ sendEvent("progress", mapOf(
76
+ "percent" to event.percent,
77
+ "phase" to event.phase.name.lowercase(),
78
+ "elapsedSeconds" to event.elapsedSeconds
79
+ ))
80
+ },
81
+ onQualityWarning = { warning: QualityWarning ->
82
+ Log.d(TAG, "Quality warning: ${warning.message}")
83
+ sendEvent("qualityWarning", mapOf(
84
+ "type" to warning.type.name.lowercase(),
85
+ "message" to warning.message,
86
+ "severity" to warning.severity.name.lowercase()
87
+ ))
88
+ },
89
+ onQualityState = { state: QualityState ->
90
+ sendEvent("qualityState", mapOf(
91
+ "lighting" to mapOf(
92
+ "brightness" to state.lighting.brightness,
93
+ "isOk" to state.lighting.isOk,
94
+ "isTooDark" to state.lighting.isTooDark
95
+ ),
96
+ "motion" to mapOf(
97
+ "magnitude" to state.motion.motionMagnitude,
98
+ "isStill" to state.motion.isStill
99
+ ),
100
+ "pose" to mapOf(
101
+ "yaw" to state.pose.yaw,
102
+ "pitch" to state.pose.pitch,
103
+ "isFacingForward" to state.pose.isFacingForward
104
+ ),
105
+ "isReady" to state.isReady,
106
+ "messages" to state.messages
107
+ ))
108
+ }
109
+ )
110
+
111
+ val config = CircadifyConfig(
112
+ apiKey = apiKey,
113
+ debug = true,
114
+ callbacks = callbacks
115
+ )
116
+
117
+ sdk = CircadifySDK(
118
+ context = appContext.reactContext ?: activity,
119
+ config = config
120
+ )
121
+
122
+ val demographics = buildDemographics(demographicsMap)
123
+
124
+ val result = sdk!!.measureVitals(
125
+ MeasurementOptions(
126
+ lifecycleOwner = activity as androidx.lifecycle.LifecycleOwner,
127
+ previewView = previewForSdk,
128
+ demographics = demographics
129
+ )
130
+ )
131
+
132
+ // Build result map — only include keys for values that exist
133
+ val resultMap = mutableMapOf<String, Any?>(
134
+ "heartRate" to result.heartRate
135
+ )
136
+ result.confidence?.let { resultMap["confidence"] = it }
137
+ result.hrv?.let { resultMap["hrv"] = it }
138
+ result.respiratoryRate?.let { resultMap["respiratoryRate"] = it }
139
+ result.spo2?.let { resultMap["spo2"] = it }
140
+ result.systolicBp?.let { resultMap["systolicBp"] = it }
141
+ result.diastolicBp?.let { resultMap["diastolicBp"] = it }
142
+ promise.resolve(resultMap)
143
+ } catch (e: CircadifyError) {
144
+ promise.reject(
145
+ "CIRCADIFY_ERROR",
146
+ "${e.code}: ${e.message}",
147
+ e
148
+ )
149
+ } catch (e: Exception) {
150
+ promise.reject(
151
+ "MEASUREMENT_FAILED",
152
+ e.message ?: "Unknown measurement error",
153
+ e
154
+ )
155
+ }
156
+ }
157
+ }
158
+
159
+ AsyncFunction("hasCameraPermission") { promise: Promise ->
160
+ promise.resolve(hasCameraPermission())
161
+ }
162
+
163
+ AsyncFunction("requestCameraPermission") { promise: Promise ->
164
+ // Android camera permission uses ActivityResultLauncher which
165
+ // requires a registry tied to the host Activity/Fragment.
166
+ // Expo Modules don't expose launcher registration APIs yet.
167
+ // As a graceful fallback we check current status — consumers
168
+ // should ensure camera permission is pre-granted on Android
169
+ // (the Expo config plugin adds CAMERA permission by default).
170
+ Log.w(TAG, "requestCameraPermission: ActivityResultLauncher not available in Expo Module; returning current status")
171
+ promise.resolve(hasCameraPermission())
172
+ }
173
+
174
+ AsyncFunction("cancel") { promise: Promise ->
175
+ scope.launch {
176
+ try {
177
+ sdk?.cancel()
178
+ promise.resolve(null)
179
+ } catch (e: Exception) {
180
+ promise.reject("CANCEL_FAILED", e.message, e)
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ private fun hasCameraPermission(): Boolean {
187
+ val context = appContext.reactContext ?: return false
188
+ return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
189
+ PackageManager.PERMISSION_GRANTED
190
+ }
191
+
192
+ private fun buildDemographics(map: Map<String, Any>?): Demographics? {
193
+ if (map.isNullOrEmpty()) return null
194
+ return Demographics(
195
+ age = (map["age"] as? Number)?.toInt(),
196
+ sex = (map["sex"] as? String)?.let {
197
+ try { Sex.valueOf(it) } catch (_: Exception) { null }
198
+ },
199
+ fitzpatrick = (map["fitzpatrick"] as? Number)?.toInt()
200
+ )
201
+ }
202
+ }
@@ -0,0 +1,60 @@
1
+ package expo.modules.circadify
2
+
3
+ import android.content.Context
4
+ import android.graphics.Outline
5
+ import android.util.Log
6
+ import android.view.View
7
+ import android.view.ViewGroup
8
+ import android.view.ViewOutlineProvider
9
+ import androidx.camera.view.PreviewView
10
+ import expo.modules.kotlin.AppContext
11
+ import expo.modules.kotlin.views.ExpoView
12
+
13
+ /**
14
+ * Expo native view wrapping a CameraX [PreviewView].
15
+ *
16
+ * When mounted in the React Native view tree the [previewView] is registered
17
+ * as [CircadifyModule.currentPreview] so the measurement flow can bind
18
+ * CameraX to it. On unmount the reference is cleared.
19
+ *
20
+ * Usage from JS:
21
+ * <CircadifyPreviewView style={{ flex: 1 }} />
22
+ */
23
+ class CircadifyPreviewView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
24
+
25
+ override val shouldUseAndroidLayout: Boolean = true
26
+
27
+ val previewView: PreviewView = PreviewView(context).apply {
28
+ layoutParams = ViewGroup.LayoutParams(
29
+ ViewGroup.LayoutParams.MATCH_PARENT,
30
+ ViewGroup.LayoutParams.MATCH_PARENT
31
+ )
32
+ implementationMode = PreviewView.ImplementationMode.COMPATIBLE
33
+ scaleType = PreviewView.ScaleType.FILL_CENTER
34
+ }
35
+
36
+ init {
37
+ clipToPadding = true
38
+ addView(previewView)
39
+ CircadifyModule.currentPreview = previewView
40
+ Log.d("CircadifyPreviewView", "PreviewView registered: " +
41
+ "implMode=${previewView.implementationMode}, " +
42
+ "size=${previewView.width}x${previewView.height}, " +
43
+ "surfaceProvider=${previewView.surfaceProvider}")
44
+
45
+ clipToOutline = true
46
+ outlineProvider = object : ViewOutlineProvider() {
47
+ override fun getOutline(view: View, outline: Outline) {
48
+ val radius = 14f * resources.displayMetrics.density
49
+ outline.setRoundRect(0, 0, view.width, view.height, radius)
50
+ }
51
+ }
52
+ }
53
+
54
+ override fun onDetachedFromWindow() {
55
+ super.onDetachedFromWindow()
56
+ if (CircadifyModule.currentPreview == previewView) {
57
+ CircadifyModule.currentPreview = null
58
+ }
59
+ }
60
+ }