diff --git a/.DS_Store b/.DS_Store index 382132b..f52b672 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/homeflow/android/.gitignore b/homeflow/android/.gitignore new file mode 100644 index 0000000..8a6be07 --- /dev/null +++ b/homeflow/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/homeflow/android/app/build.gradle b/homeflow/android/app/build.gradle new file mode 100644 index 0000000..81fe577 --- /dev/null +++ b/homeflow/android/app/build.gradle @@ -0,0 +1,182 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * 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 { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean() + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* 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") + + /* 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. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // 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 in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization). + */ +def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * 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.chehan.homeflow' + defaultConfig { + applicationId 'com.chehan.homeflow' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0.0" + + buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" + } + 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 + def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false' + shrinkResources enableShrinkResources.toBoolean() + minifyEnabled enableMinifyInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true' + crunchPngs enablePngCrunchInRelease.toBoolean() + } + } + packagingOptions { + jniLibs { + def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false' + useLegacyPackaging enableLegacyPackaging.toBoolean() + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/homeflow/android/app/debug.keystore b/homeflow/android/app/debug.keystore new file mode 100644 index 0000000..364e105 Binary files /dev/null and b/homeflow/android/app/debug.keystore differ diff --git a/homeflow/android/app/proguard-rules.pro b/homeflow/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/homeflow/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# 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 + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/homeflow/android/app/src/debug/AndroidManifest.xml b/homeflow/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/homeflow/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/homeflow/android/app/src/debugOptimized/AndroidManifest.xml b/homeflow/android/app/src/debugOptimized/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/homeflow/android/app/src/debugOptimized/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/homeflow/android/app/src/main/AndroidManifest.xml b/homeflow/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..573b5d8 --- /dev/null +++ b/homeflow/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/homeflow/android/app/src/main/java/com/chehan/homeflow/MainActivity.kt b/homeflow/android/app/src/main/java/com/chehan/homeflow/MainActivity.kt new file mode 100644 index 0000000..bd1cc16 --- /dev/null +++ b/homeflow/android/app/src/main/java/com/chehan/homeflow/MainActivity.kt @@ -0,0 +1,65 @@ +package com.chehan.homeflow +import expo.modules.splashscreen.SplashScreenManager + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + // setTheme(R.style.AppTheme); + // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af + SplashScreenManager.registerOnActivity(this) + // @generated end expo-splashscreen + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * 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 { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/homeflow/android/app/src/main/java/com/chehan/homeflow/MainApplication.kt b/homeflow/android/app/src/main/java/com/chehan/homeflow/MainApplication.kt new file mode 100644 index 0000000..ec13548 --- /dev/null +++ b/homeflow/android/app/src/main/java/com/chehan/homeflow/MainApplication.kt @@ -0,0 +1,56 @@ +package com.chehan.homeflow + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactNativeHost + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + DefaultNewArchitectureEntryPoint.releaseLevel = try { + ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) + } catch (e: IllegalArgumentException) { + ReleaseLevel.STABLE + } + loadReactNative(this) + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/homeflow/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/homeflow/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..31df827 Binary files /dev/null and b/homeflow/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/homeflow/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/homeflow/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..ef243aa Binary files /dev/null and b/homeflow/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/homeflow/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/homeflow/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..e9d5474 Binary files /dev/null and b/homeflow/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/homeflow/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/homeflow/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..d61da15 Binary files /dev/null and b/homeflow/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/homeflow/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/homeflow/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..4aeed11 Binary files /dev/null and b/homeflow/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/homeflow/android/app/src/main/res/drawable/ic_launcher_background.xml b/homeflow/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/homeflow/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/homeflow/android/app/src/main/res/drawable/rn_edit_text_material.xml b/homeflow/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/homeflow/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/homeflow/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/homeflow/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/homeflow/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/homeflow/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/homeflow/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..9764d2a --- /dev/null +++ b/homeflow/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..a858de0 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp new file mode 100644 index 0000000..c02225f Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..49a260f Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..fc1087b Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..7b9ac97 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..c75dbb9 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp new file mode 100644 index 0000000..1e9b3d1 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..46ea7d0 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..12a3278 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a9b305d Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..b2658c0 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..dde133a Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..671b75f Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..2809d31 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..535e6d7 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..858ddc8 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..55a3581 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..ba86612 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..9dbe470 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..357395c Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..d31ce8f Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp new file mode 100644 index 0000000..d190cdd Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..3468026 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp new file mode 100644 index 0000000..8736a27 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp differ diff --git a/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4185f23 Binary files /dev/null and b/homeflow/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/homeflow/android/app/src/main/res/values-night/colors.xml b/homeflow/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..5429410 --- /dev/null +++ b/homeflow/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1,3 @@ + + #000000 + \ No newline at end of file diff --git a/homeflow/android/app/src/main/res/values/colors.xml b/homeflow/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..dd82deb --- /dev/null +++ b/homeflow/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #ffffff + #E6F4FE + #023c69 + #ffffff + \ No newline at end of file diff --git a/homeflow/android/app/src/main/res/values/strings.xml b/homeflow/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..60a9ea1 --- /dev/null +++ b/homeflow/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + HomeFlow + automatic + contain + false + \ No newline at end of file diff --git a/homeflow/android/app/src/main/res/values/styles.xml b/homeflow/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..45a97e6 --- /dev/null +++ b/homeflow/android/app/src/main/res/values/styles.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/homeflow/android/build.gradle b/homeflow/android/build.gradle new file mode 100644 index 0000000..0554dd1 --- /dev/null +++ b/homeflow/android/build.gradle @@ -0,0 +1,24 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + 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') + } +} + +allprojects { + repositories { + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} + +apply plugin: "expo-root-project" +apply plugin: "com.facebook.react.rootproject" diff --git a/homeflow/android/gradle.properties b/homeflow/android/gradle.properties new file mode 100644 index 0000000..8e39f82 --- /dev/null +++ b/homeflow/android/gradle.properties @@ -0,0 +1,65 @@ +# 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 + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -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=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false + +# Specifies whether the app is configured to use edge-to-edge via the app config or plugin +# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge. +expo.edgeToEdgeEnabled=true diff --git a/homeflow/android/gradle/wrapper/gradle-wrapper.jar b/homeflow/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/homeflow/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/homeflow/android/gradle/wrapper/gradle-wrapper.properties b/homeflow/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/homeflow/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/homeflow/android/gradlew b/homeflow/android/gradlew new file mode 100755 index 0000000..7f94d3d --- /dev/null +++ b/homeflow/android/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 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 + +CLASSPATH="\\\"\\\"" + + +# 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" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + 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, 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" \ + -classpath "$CLASSPATH" \ + -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" "$@" diff --git a/homeflow/android/gradlew.bat b/homeflow/android/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/homeflow/android/gradlew.bat @@ -0,0 +1,94 @@ +@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 + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -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 diff --git a/homeflow/android/settings.gradle b/homeflow/android/settings.gradle new file mode 100644 index 0000000..8bc2beb --- /dev/null +++ b/homeflow/android/settings.gradle @@ -0,0 +1,39 @@ +pluginManagement { + def reactNativeGradlePlugin = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") + }.standardOutput.asText.get().trim() + ).getParentFile().absolutePath + includeBuild(reactNativeGradlePlugin) + + def expoPluginsPath = new File( + providers.exec { + workingDir(rootDir) + commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") + }.standardOutput.asText.get().trim(), + "../android/expo-gradle-plugin" + ).absolutePath + includeBuild(expoPluginsPath) +} + +plugins { + id("com.facebook.react.settings") + id("expo-autolinking-settings") +} + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) + } +} +expoAutolinking.useExpoModules() + +rootProject.name = 'HomeFlow' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/homeflow/app.config.js b/homeflow/app.config.js index 43b08b1..17f1f12 100644 --- a/homeflow/app.config.js +++ b/homeflow/app.config.js @@ -9,9 +9,12 @@ module.exports = { userInterfaceStyle: "automatic", newArchEnabled: true, ios: { - supportsTablet: true + supportsTablet: true, + bundleIdentifier: "com.chehan.homeflow", + deploymentTarget: "16.0" }, android: { + package: "com.chehan.homeflow", adaptiveIcon: { backgroundColor: "#E6F4FE", foregroundImage: "./assets/images/android-icon-foreground.png", @@ -39,11 +42,27 @@ module.exports = { } } ], + [ + "expo-build-properties", + { + ios: { + deploymentTarget: "16.0" + } + } + ], + "./plugins/withMinDeploymentTarget", [ "@kingstinct/react-native-healthkit", { "NSHealthShareUsageDescription": "This app needs access to your health data to display your health metrics and track your progress.", - "NSHealthUpdateUsageDescription": "This app needs permission to save health data to track your activities." + "NSHealthUpdateUsageDescription": "This app needs permission to save health data to track your activities.", + "background": true + } + ], + [ + "./plugins/withClinicalRecords", + { + "usageDescription": "HomeFlow would like to access your clinical health records to import medications, lab results, and conditions — reducing manual data entry." } ] ], diff --git a/homeflow/app/(onboarding)/_layout.tsx b/homeflow/app/(onboarding)/_layout.tsx index f9f3983..f5904c0 100644 --- a/homeflow/app/(onboarding)/_layout.tsx +++ b/homeflow/app/(onboarding)/_layout.tsx @@ -61,6 +61,18 @@ export default function OnboardingLayout() { animation: 'slide_from_right', }} /> + + {getPhaseText()} diff --git a/homeflow/app/(onboarding)/complete.tsx b/homeflow/app/(onboarding)/complete.tsx index 0e074b3..df68daa 100644 --- a/homeflow/app/(onboarding)/complete.tsx +++ b/homeflow/app/(onboarding)/complete.tsx @@ -18,6 +18,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { STUDY_INFO, OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; +import { notifyOnboardingComplete } from '@/hooks/use-onboarding-status'; import { ContinueButton, DevToolBar } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; @@ -88,6 +89,9 @@ export default function CompleteScreen() { // Mark onboarding as complete await OnboardingService.complete(); + // Notify listeners that onboarding is complete (triggers layout re-render) + notifyOnboardingComplete(); + // Navigate to main app router.replace('/(tabs)'); }; diff --git a/homeflow/app/(onboarding)/health-data-test.tsx b/homeflow/app/(onboarding)/health-data-test.tsx new file mode 100644 index 0000000..ad4b7fd --- /dev/null +++ b/homeflow/app/(onboarding)/health-data-test.tsx @@ -0,0 +1,669 @@ +/** + * Health Data Test Screen (Dev Only) + * + * Sits between Permissions and Medical History in the onboarding flow. + * In production (__DEV__ === false), auto-skips to medical history. + * In dev mode, shows a full testing UI for HealthKit + Clinical Records queries. + */ + +import React, { useState, useCallback, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + useColorScheme, + TouchableOpacity, + ActivityIndicator, + Platform, +} from 'react-native'; +import { useRouter, Href } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Colors, StanfordColors, Spacing } from '@/constants/theme'; +import { OnboardingStep } from '@/lib/constants'; +import { OnboardingService } from '@/lib/services/onboarding-service'; +import { + OnboardingProgressBar, + ContinueButton, + DevToolBar, +} from '@/components/onboarding'; +import { IconSymbol } from '@/components/ui/icon-symbol'; + +import { + requestHealthPermissions, + getDailyActivity, + getSleep, + getVitals, + areClinicalRecordsAvailable, + requestClinicalPermissions, + getClinicalMedications, + getClinicalLabResults, + getClinicalConditions, + getClinicalProcedures, +} from '@/lib/services/healthkit'; +import type { DateRange } from '@/lib/services/healthkit'; + +// ── Types ─────────────────────────────────────────────────────────── + +type TestStatus = 'idle' | 'running' | 'success' | 'error'; + +interface TestResult { + label: string; + status: TestStatus; + data?: unknown; + error?: string; + count?: number; +} + +// ── Helpers ───────────────────────────────────────────────────────── + +function getLast7DaysRange(): DateRange { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 7); + return { startDate: start, endDate: end }; +} + +function truncateJSON(obj: unknown, maxLength = 500): string { + const str = JSON.stringify(obj, null, 2); + if (str.length <= maxLength) return str; + return str.slice(0, maxLength) + '\n... (truncated)'; +} + +// ── Main Screen ───────────────────────────────────────────────────── + +export default function HealthDataTestScreen() { + const router = useRouter(); + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + + // In production, auto-skip this screen + useEffect(() => { + if (!__DEV__) { + (async () => { + await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); + router.replace('/(onboarding)/medical-history' as Href); + })(); + } + }, [router]); + + const handleContinue = async () => { + await OnboardingService.goToStep(OnboardingStep.MEDICAL_HISTORY); + router.push('/(onboarding)/medical-history' as Href); + }; + + // Don't render test UI in production + if (!__DEV__) { + return ( + + + + ); + } + + return ( + + + + + + + + + + Health Data Test + + + + + Dev-only screen. Test HealthKit and Clinical Records queries before proceeding. + + + + + + + + + + + + + + + + + + + + + ); +} + +// ── Status Banner ─────────────────────────────────────────────────── + +function StatusBanner({ + colors, + colorScheme, +}: { + colors: typeof Colors.light; + colorScheme: string | null | undefined; +}) { + const isIOS = Platform.OS === 'ios'; + const clinicalAvailable = isIOS ? areClinicalRecordsAvailable() : false; + + return ( + + + + + + ); +} + +function StatusRow({ label, value }: { label: string; value: string }) { + return ( + + {label} + + {value} + + + ); +} + +// ── Section Header ────────────────────────────────────────────────── + +function SectionHeader({ + title, + colors, +}: { + title: string; + colors: typeof Colors.light; +}) { + return ( + {title} + ); +} + +// ── Permission Tests ──────────────────────────────────────────────── + +function PermissionTests({ + colors, + colorScheme, +}: { + colors: typeof Colors.light; + colorScheme: string | null | undefined; +}) { + const [hkResult, setHkResult] = useState({ + label: 'Request HealthKit Permissions', + status: 'idle', + }); + const [crResult, setCrResult] = useState({ + label: 'Request Clinical Records Permissions', + status: 'idle', + }); + + const handleHKPermissions = useCallback(async () => { + setHkResult((prev) => ({ ...prev, status: 'running' })); + try { + const result = await requestHealthPermissions(); + setHkResult({ + label: 'Request HealthKit Permissions', + status: result.success ? 'success' : 'error', + data: result, + error: result.success ? undefined : result.note, + }); + } catch (e) { + setHkResult({ + label: 'Request HealthKit Permissions', + status: 'error', + error: e instanceof Error ? e.message : String(e), + }); + } + }, []); + + const handleCRPermissions = useCallback(async () => { + setCrResult((prev) => ({ ...prev, status: 'running' })); + try { + const result = await requestClinicalPermissions(); + setCrResult({ + label: 'Request Clinical Records Permissions', + status: result.success ? 'success' : 'error', + data: result, + error: result.success ? undefined : result.note, + }); + } catch (e) { + setCrResult({ + label: 'Request Clinical Records Permissions', + status: 'error', + error: e instanceof Error ? e.message : String(e), + }); + } + }, []); + + return ( + + + + + ); +} + +// ── HealthKit Data Tests ──────────────────────────────────────────── + +function HealthKitTests({ + colors, + colorScheme, +}: { + colors: typeof Colors.light; + colorScheme: string | null | undefined; +}) { + const [results, setResults] = useState([ + { label: 'Daily Activity', status: 'idle' }, + { label: 'Sleep', status: 'idle' }, + { label: 'Vitals', status: 'idle' }, + ]); + + const runTest = useCallback( + async (index: number, fn: (range: DateRange) => Promise, label: string) => { + setResults((prev) => { + const next = [...prev]; + next[index] = { ...next[index], status: 'running' }; + return next; + }); + try { + const range = getLast7DaysRange(); + const data = await fn(range); + const count = Array.isArray(data) ? data.length : undefined; + setResults((prev) => { + const next = [...prev]; + next[index] = { label, status: 'success', data, count }; + return next; + }); + } catch (e) { + setResults((prev) => { + const next = [...prev]; + next[index] = { label, status: 'error', error: e instanceof Error ? e.message : String(e) }; + return next; + }); + } + }, + [], + ); + + const handleRunAll = useCallback(async () => { + await Promise.all([ + runTest(0, getDailyActivity, 'Daily Activity'), + runTest(1, getSleep, 'Sleep'), + runTest(2, getVitals, 'Vitals'), + ]); + }, [runTest]); + + return ( + + + {results.map((r, i) => ( + + ))} + + ); +} + +// ── Clinical Record Tests ─────────────────────────────────────────── + +function ClinicalRecordTests({ + colors, + colorScheme, +}: { + colors: typeof Colors.light; + colorScheme: string | null | undefined; +}) { + const [results, setResults] = useState([ + { label: 'Medications', status: 'idle' }, + { label: 'Lab Results', status: 'idle' }, + { label: 'Conditions', status: 'idle' }, + { label: 'Procedures', status: 'idle' }, + ]); + + const runTest = useCallback( + async (index: number, fn: () => Promise, label: string) => { + setResults((prev) => { + const next = [...prev]; + next[index] = { ...next[index], status: 'running' }; + return next; + }); + try { + const data = await fn(); + const count = Array.isArray(data) ? data.length : undefined; + setResults((prev) => { + const next = [...prev]; + next[index] = { label, status: 'success', data, count }; + return next; + }); + } catch (e) { + setResults((prev) => { + const next = [...prev]; + next[index] = { label, status: 'error', error: e instanceof Error ? e.message : String(e) }; + return next; + }); + } + }, + [], + ); + + const handleRunAll = useCallback(async () => { + await Promise.all([ + runTest(0, getClinicalMedications, 'Medications'), + runTest(1, getClinicalLabResults, 'Lab Results'), + runTest(2, getClinicalConditions, 'Conditions'), + runTest(3, getClinicalProcedures, 'Procedures'), + ]); + }, [runTest]); + + return ( + + + {results.map((r) => ( + + ))} + + ); +} + +// ── Shared UI Components ──────────────────────────────────────────── + +function TestButton({ + result, + onPress, + colors, + colorScheme, +}: { + result: TestResult; + onPress: () => void; + colors: typeof Colors.light; + colorScheme: string | null | undefined; +}) { + const statusIcon = + result.status === 'success' ? 'checkmark.circle.fill' : + result.status === 'error' ? 'xmark.circle.fill' : + result.status === 'running' ? 'arrow.clockwise' : + 'play.circle.fill'; + + const statusColor = + result.status === 'success' ? '#34C759' : + result.status === 'error' ? '#FF3B30' : + result.status === 'running' ? '#FF9500' : + '#007AFF'; + + return ( + + + + + {result.label} + + {result.status === 'running' && ( + + )} + + {result.error && ( + {result.error} + )} + {result.status === 'success' && result.data && ( + + {JSON.stringify(result.data)} + + )} + + ); +} + +function TestResultCard({ + result, + colors, + colorScheme, +}: { + result: TestResult; + colors: typeof Colors.light; + colorScheme: string | null | undefined; +}) { + const [expanded, setExpanded] = useState(false); + + const statusColor = + result.status === 'success' ? '#34C759' : + result.status === 'error' ? '#FF3B30' : + result.status === 'running' ? '#FF9500' : + '#8E8E93'; + + const statusText = + result.status === 'success' && result.count !== undefined + ? `${result.count} record${result.count !== 1 ? 's' : ''}` + : result.status === 'running' ? 'Fetching...' + : result.status === 'error' ? 'Error' + : 'Not run'; + + return ( + + result.data && setExpanded(!expanded)} + activeOpacity={result.data ? 0.7 : 1} + > + + + {result.label} + + {result.status === 'running' && ( + + )} + + {statusText} + + {result.data && ( + + )} + + + {result.error && ( + {result.error} + )} + + {expanded && result.data && ( + + + {truncateJSON(result.data, 2000)} + + + )} + + ); +} + +function RunAllButton({ onPress, label }: { onPress: () => void; label: string }) { + return ( + + + {label} + + ); +} + +// ── Styles ────────────────────────────────────────────────────────── + +const styles = StyleSheet.create({ + container: { flex: 1 }, + header: { paddingTop: Spacing.sm }, + scrollView: { flex: 1 }, + scrollContent: { padding: Spacing.screenHorizontal, paddingBottom: 40 }, + titleContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + marginBottom: Spacing.xs, + marginTop: Spacing.md, + }, + title: { fontSize: 26, fontWeight: '700' }, + subtitle: { + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + marginBottom: Spacing.lg, + }, + banner: { + borderRadius: 12, + padding: 14, + marginBottom: Spacing.lg, + gap: 8, + }, + statusRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + statusLabel: { + fontSize: 14, + color: '#8E8E93', + fontWeight: '500', + }, + statusValue: { + fontSize: 14, + fontWeight: '600', + }, + sectionHeader: { + fontSize: 18, + fontWeight: '700', + marginTop: Spacing.md, + marginBottom: Spacing.sm, + }, + testButtonContainer: { + marginBottom: 10, + }, + testButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 14, + borderRadius: 12, + gap: 12, + }, + testButtonLabel: { + fontSize: 15, + fontWeight: '600', + flex: 1, + }, + resultCard: { + borderRadius: 12, + marginBottom: 8, + overflow: 'hidden', + }, + resultCardHeader: { + flexDirection: 'row', + alignItems: 'center', + padding: 14, + gap: 10, + }, + statusDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + resultLabel: { + fontSize: 15, + fontWeight: '600', + flex: 1, + }, + resultStatus: { + fontSize: 13, + fontWeight: '500', + }, + errorText: { + fontSize: 12, + color: '#FF3B30', + paddingHorizontal: 14, + paddingBottom: 10, + }, + successNote: { + fontSize: 11, + color: '#34C759', + paddingHorizontal: 14, + paddingBottom: 10, + }, + jsonContainer: { + backgroundColor: '#1A1A2E', + marginHorizontal: 10, + marginBottom: 10, + borderRadius: 8, + padding: 12, + }, + jsonText: { + fontSize: 11, + fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace', + color: '#A5D6A7', + lineHeight: 16, + }, + runAllButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: '#007AFF', + borderRadius: 10, + paddingVertical: 10, + marginBottom: 12, + }, + runAllLabel: { + fontSize: 14, + fontWeight: '600', + color: '#FFFFFF', + }, + footer: { + padding: Spacing.md, + paddingBottom: Spacing.lg, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(0,0,0,0.1)', + }, +}); diff --git a/homeflow/app/(onboarding)/index.tsx b/homeflow/app/(onboarding)/index.tsx index ec63231..af93e91 100644 --- a/homeflow/app/(onboarding)/index.tsx +++ b/homeflow/app/(onboarding)/index.tsx @@ -8,7 +8,7 @@ import React, { useEffect } from 'react'; import { View, ActivityIndicator, StyleSheet } from 'react-native'; import { Redirect, useRouter, Href } from 'expo-router'; -import { useOnboardingStep } from '@/hooks/use-onboarding-status'; +import { useOnboardingStep, useOnboardingStatus } from '@/hooks/use-onboarding-status'; import { OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; import { StanfordColors } from '@/constants/theme'; @@ -16,6 +16,7 @@ import { StanfordColors } from '@/constants/theme'; export default function OnboardingRouter() { const router = useRouter(); const currentStep = useOnboardingStep(); + const isOnboardingComplete = useOnboardingStatus(); useEffect(() => { let cancelled = false; @@ -30,12 +31,19 @@ export default function OnboardingRouter() { } } - initializeOnboarding(); + if (!isOnboardingComplete) { + initializeOnboarding(); + } return () => { cancelled = true; }; - }, [router]); + }, [router, isOnboardingComplete]); + + // If onboarding is already finished, skip to tabs immediately + if (isOnboardingComplete === true) { + return ; + } // Show loading while determining step if (currentStep === null) { @@ -60,11 +68,18 @@ export default function OnboardingRouter() { case OnboardingStep.PERMISSIONS: return ; + case OnboardingStep.HEALTH_DATA_TEST: + return ; + + case OnboardingStep.MEDICAL_HISTORY: + return ; + case OnboardingStep.BASELINE_SURVEY: return ; case OnboardingStep.COMPLETE: - return ; + // Show complete screen - user needs to click "Get Started" to finish + return ; default: return ; diff --git a/homeflow/app/(onboarding)/medical-history.tsx b/homeflow/app/(onboarding)/medical-history.tsx new file mode 100644 index 0000000..6ea47e2 --- /dev/null +++ b/homeflow/app/(onboarding)/medical-history.tsx @@ -0,0 +1,564 @@ +/** + * Medical History Chat Screen + * + * Collects medical history through natural conversation with AI assistant. + * This screen appears after consent and permissions (Apple Health / Throne). + * + * Flow: + * 1. Loading phase: Fetch clinical records + HealthKit demographics in parallel + * 2. Build prefill data from health records (FHIR normalization) + * 3. If all medical data is prefilled → show summary, skip chatbot + * 4. Otherwise → launch chatbot with modified prompt that skips known fields + * 5. If no records available → fall back to full chatbot (original behavior) + */ + +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + useColorScheme, + Animated, + Keyboard, + TouchableWithoutFeedback, + ActivityIndicator, + ScrollView, +} from 'react-native'; +import { useRouter, Href } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Constants from 'expo-constants'; +import { ChatView, ChatProvider } from '@spezivibe/chat'; +import { Colors, StanfordColors, Spacing } from '@/constants/theme'; +import { OnboardingStep, STUDY_INFO } from '@/lib/constants'; +import { OnboardingService } from '@/lib/services/onboarding-service'; +import { OnboardingProgressBar, ContinueButton, DevToolBar } from '@/components/onboarding'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { getAllClinicalRecords } from '@/lib/services/healthkit'; +import { getDemographics } from '@/lib/services/healthkit/HealthKitClient'; +import { + buildMedicalHistoryPrefill, + isFullyPrefilled, + getKnownFieldsSummary, + buildModifiedSystemPrompt, + type MedicalHistoryPrefill, +} from '@/lib/services/fhir'; + +/** + * Fallback system prompt used when no clinical records are available. + */ +const FALLBACK_SYSTEM_PROMPT = `You are a friendly research assistant collecting medical history for the HomeFlow BPH study. The participant has already been confirmed eligible and has given informed consent. Now you need to collect their medical history. + +## Study Information +- Name: ${STUDY_INFO.name} +- Institution: ${STUDY_INFO.institution} +- Purpose: Track voiding patterns and symptoms before/after bladder outlet surgery + +## Context +The participant has already: +- Passed eligibility screening (has iPhone, has BPH/LUTS, planning bladder outlet surgery) +- Signed informed consent +- Granted permissions for Apple Health and Throne uroflow + +## What to Collect + +### Data We Get Automatically from Apple Health (DO NOT ASK): +- Age / Date of Birth +- Biological Sex +- Height +- Weight / BMI + +### Data You MUST Collect (not available from Apple Health): + +#### 1. Demographics +- Full name (for study records) +- Ethnicity: Hispanic/Latino or Not Hispanic/Latino +- Race + +#### 2. BPH/LUTS Medications (BE THOROUGH - ask about each category) +Go through each medication class: +1. Alpha blockers: "Are you taking tamsulosin (Flomax), alfuzosin (Uroxatral), silodosin (Rapaflo), doxazosin, or terazosin?" +2. 5-alpha reductase inhibitors: "Are you taking finasteride (Proscar) or dutasteride (Avodart)?" +3. Anticholinergics: "Are you taking oxybutynin (Ditropan), tolterodine (Detrol), solifenacin (Vesicare), or trospium (Sanctura)?" +4. Beta-3 agonists: "Are you taking mirabegron (Myrbetriq) or vibegron (Gemtesa)?" +5. Any other bladder or prostate medications + +#### 3. Surgical History +- Prior BPH/prostate surgeries: Ask about TURP, HoLEP, GreenLight, UroLift, Rezum, Aquablation, or any other prostate procedures. Get type AND approximate date. +- General surgical history: Any other past surgeries (type and approximate year) + +#### 4. Lab Values (ask if they know these) +- PSA (Prostate Specific Antigen): Most recent value and when it was done. Explain: "This is a blood test often done for prostate screening." +- Urinalysis: Any recent urine test results, especially if anything abnormal was found + +#### 5. Key Medical Conditions (CRITICAL - must ask about these specifically) +- **Diabetes**: Ask directly! If yes, ask about HbA1c level (explain: "This is a blood sugar control number, usually between 5-10%") +- **Hypertension**: High blood pressure - are they diagnosed? Is it controlled with medication? +- Other significant conditions + +#### 6. Clinical Measurements (if they've had these tests) +- PVR (Post-Void Residual) or bladder scan: "Have you had a bladder scan after urinating? If so, what was the residual volume in mL?" +- Clinic uroflow: "Have you done a urine flow test at your doctor's office? If so, what was your Qmax (maximum flow rate)?" +- Mobility status: How active are they? Any limitations? + +#### 7. Upcoming Surgery +- Date of scheduled BPH surgery (if known) +- Type of surgery planned (TURP, HoLEP, UroLift, Rezum, etc.) + +## Conversation Guidelines +- Be warm, conversational, and empathetic +- Ask 2-3 related items at a time, don't overwhelm +- Group questions logically (all medications together, then conditions, etc.) +- Acknowledge symptoms supportively when mentioned +- If they don't know a value (like PSA or HbA1c), that's OK - just note "unknown" and continue +- NEVER give medical advice or interpret their values + +## Important Response Markers (include these exact phrases) +When ALL medical history sections are complete: [HISTORY_COMPLETE] + +## Conversation Flow +1. Start with a brief introduction: "Now let's collect some medical history. We'll automatically get things like your age and weight from Apple Health, but I need to ask you about medications, conditions, and a few other things." +2. Work through sections in order: Demographics → Medications → Surgeries → Labs → Conditions → Clinical data → Planned surgery +3. Before finishing, summarize: "Let me confirm what I have..." then list key points +4. End with: "I have everything I need. [HISTORY_COMPLETE] You can tap Continue to proceed." + +## Start the Conversation +"Thanks for completing the consent process! Now I need to collect some medical history. We'll pull basic info like your age and weight from Apple Health, so I just need to ask about a few other things. + +Let's start with some basic demographics - could you tell me your full name?"`; + +type MedicalHistoryPhase = 'loading' | 'collecting' | 'all_prefilled' | 'complete'; + +export default function MedicalHistoryScreen() { + const router = useRouter(); + const colorScheme = useColorScheme(); + const colors = Colors[colorScheme ?? 'light']; + + const [phase, setPhase] = useState('loading'); + const [canContinue, setCanContinue] = useState(false); + const [systemPrompt, setSystemPrompt] = useState(FALLBACK_SYSTEM_PROMPT); + const [prefillData, setPrefillData] = useState(null); + const [knownSummary, setKnownSummary] = useState([]); + + // Animation for continue button + const buttonOpacity = useRef(new Animated.Value(0)).current; + + // Get API key from environment + const apiKey = Constants.expoConfig?.extra?.openaiApiKey || process.env.EXPO_PUBLIC_OPENAI_API_KEY || ''; + + // Chat provider config + const provider: ChatProvider = useMemo( + () => ({ + type: 'openai', + apiKey, + model: 'gpt-4o-mini', + }), + [apiKey] + ); + + // ── Load clinical records + demographics on mount ───────────────── + useEffect(() => { + let cancelled = false; + + async function loadPrefillData() { + try { + // Fetch clinical records and demographics in parallel + const [clinicalRecords, demographics] = await Promise.all([ + getAllClinicalRecords().catch(() => null), + getDemographics().catch(() => ({ age: null, dateOfBirth: null, biologicalSex: null })), + ]); + + if (cancelled) return; + + // Build prefill from whatever data we got + const prefill = buildMedicalHistoryPrefill(clinicalRecords, demographics); + const known = getKnownFieldsSummary(prefill); + + setPrefillData(prefill); + setKnownSummary(known); + + if (isFullyPrefilled(prefill)) { + // All medical data sections are covered + setPhase('all_prefilled'); + setCanContinue(true); + } else if (known.length > 0) { + // Some data found — use modified prompt + const modifiedPrompt = buildModifiedSystemPrompt(prefill); + setSystemPrompt(modifiedPrompt); + setPhase('collecting'); + } else { + // No records — fall back to full chatbot + setPhase('collecting'); + } + } catch { + // On any error, fall back to full chatbot + if (!cancelled) { + setPhase('collecting'); + } + } + } + + loadPrefillData(); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (canContinue) { + Animated.spring(buttonOpacity, { + toValue: 1, + useNativeDriver: true, + tension: 50, + friction: 8, + }).start(); + } + }, [canContinue, buttonOpacity]); + + // Watch for completion marker in chat messages + const checkForMarkers = useCallback((message: string) => { + const lowerMessage = message.toLowerCase(); + + if (message.includes('[HISTORY_COMPLETE]') || (lowerMessage.includes("all set") && lowerMessage.includes("continue"))) { + setPhase('complete'); + setCanContinue(true); + } + }, []); + + const handleContinue = async () => { + // Build medication/condition lists from prefill data if available + const medications: string[] = []; + const conditions: string[] = []; + const surgicalHistory: string[] = []; + const bphTreatmentHistory: string[] = []; + + if (prefillData) { + // Collect medication names + const medEntries = [ + prefillData.medications.alphaBlockers, + prefillData.medications.fiveARIs, + prefillData.medications.anticholinergics, + prefillData.medications.beta3Agonists, + prefillData.medications.otherBPH, + ]; + for (const entry of medEntries) { + if (entry.value) { + for (const med of entry.value) { + medications.push(med.name); + if (med.drugClass !== 'unrelated') { + bphTreatmentHistory.push(med.name); + } + } + } + } + + // Collect conditions + const condEntries = [ + prefillData.conditions.diabetes, + prefillData.conditions.hypertension, + prefillData.conditions.bph, + prefillData.conditions.other, + ]; + for (const entry of condEntries) { + if (entry.value) { + for (const cond of entry.value) { + conditions.push(cond.name); + } + } + } + + // Collect procedures + if (prefillData.surgicalHistory.bphProcedures.value) { + for (const proc of prefillData.surgicalHistory.bphProcedures.value) { + surgicalHistory.push(proc.name); + bphTreatmentHistory.push(proc.name); + } + } + if (prefillData.surgicalHistory.otherProcedures.value) { + for (const proc of prefillData.surgicalHistory.otherProcedures.value) { + surgicalHistory.push(proc.name); + } + } + } + + await OnboardingService.updateData({ + medicalHistory: { + medications, + conditions, + allergies: [], + surgicalHistory, + bphTreatmentHistory, + rawTranscript: phase === 'all_prefilled' + ? 'prefilled from health records' + : 'collected via chatbot + health records', + }, + }); + + await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY); + router.push('/(onboarding)/baseline-survey' as Href); + }; + + const getPhaseText = () => { + switch (phase) { + case 'loading': + return 'Checking your health records...'; + case 'collecting': + return 'Collecting medical history...'; + case 'all_prefilled': + case 'complete': + return 'Ready to continue!'; + default: + return ''; + } + }; + + // ── Loading phase ───────────────────────────────────────────────── + if (phase === 'loading') { + return ( + + + + + + + + Checking your health records... + + + Looking for medications, conditions, and lab results + + + + + ); + } + + // ── All prefilled phase ─────────────────────────────────────────── + if (phase === 'all_prefilled') { + return ( + + + + + + + + Health Records Found + + + We found the following from your Apple Health records: + + + + {knownSummary.map((item, index) => ( + + + {item} + + ))} + + + + {"We still need a few details that aren't in your health records. The chatbot will ask only about what's missing."} + + + { + setPhase('collecting'); + setCanContinue(false); + }} + style={{ marginTop: Spacing.md }} + /> + + + + ); + } + + // ── No API key fallback ─────────────────────────────────────────── + if (!apiKey) { + return ( + + + + + + + + Medical History Chat Not Available + + + OpenAI API key not configured. For demo purposes, tap Continue to proceed. + + + + + + + ); + } + + // ── Chatbot phase (collecting / complete) ───────────────────────── + return ( + + + + + + + {getPhaseText()} + + + + + + + Starting conversation... + + + } + /> + + {canContinue && ( + + + Medical history collected. Ready for the next step. + + + + )} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + paddingTop: Spacing.sm, + }, + phaseIndicator: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: Spacing.sm, + }, + phaseDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 8, + }, + phaseText: { + fontSize: 13, + fontWeight: '500', + }, + emptyState: { + alignItems: 'center', + gap: Spacing.sm, + }, + emptyStateText: { + fontSize: 15, + }, + continueContainer: { + padding: Spacing.md, + paddingBottom: Spacing.lg, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(0,0,0,0.1)', + gap: Spacing.sm, + }, + continueHint: { + fontSize: 14, + textAlign: 'center', + }, + noApiKey: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: Spacing.screenHorizontal, + }, + noApiKeyTitle: { + fontSize: 20, + fontWeight: '600', + marginTop: Spacing.md, + marginBottom: Spacing.sm, + }, + noApiKeyText: { + fontSize: 15, + textAlign: 'center', + lineHeight: 22, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + gap: Spacing.md, + paddingHorizontal: Spacing.screenHorizontal, + }, + loadingText: { + fontSize: 18, + fontWeight: '600', + }, + loadingSubtext: { + fontSize: 14, + textAlign: 'center', + }, + prefilledContainer: { + flexGrow: 1, + alignItems: 'center', + paddingHorizontal: Spacing.screenHorizontal, + paddingTop: Spacing.xl, + gap: Spacing.md, + }, + prefilledTitle: { + fontSize: 22, + fontWeight: '700', + }, + prefilledSubtitle: { + fontSize: 15, + textAlign: 'center', + }, + summaryCard: { + width: '100%', + borderRadius: 12, + padding: Spacing.md, + gap: Spacing.sm, + marginTop: Spacing.sm, + }, + summaryRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 8, + }, + summaryText: { + fontSize: 15, + flex: 1, + }, + prefilledNote: { + fontSize: 13, + textAlign: 'center', + lineHeight: 20, + paddingHorizontal: Spacing.md, + }, +}); diff --git a/homeflow/app/(onboarding)/permissions.tsx b/homeflow/app/(onboarding)/permissions.tsx index eb5b7b8..54ee630 100644 --- a/homeflow/app/(onboarding)/permissions.tsx +++ b/homeflow/app/(onboarding)/permissions.tsx @@ -22,6 +22,11 @@ import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { OnboardingStep } from '@/lib/constants'; import { OnboardingService } from '@/lib/services/onboarding-service'; import { ThroneService } from '@/lib/services/throne-service'; +import { + requestHealthPermissions, + areClinicalRecordsAvailable, + requestClinicalPermissions, +} from '@/lib/services/healthkit'; import { OnboardingProgressBar, PermissionCard, @@ -31,17 +36,6 @@ import { } from '@/components/onboarding'; import { IconSymbol } from '@/components/ui/icon-symbol'; -// Import HealthKit conditionally -let HealthKitService: any = null; -if (Platform.OS === 'ios') { - try { - const healthkit = require('@spezivibe/healthkit'); - HealthKitService = healthkit.HealthKitService; - } catch { - // HealthKit not available - } -} - export default function PermissionsScreen() { const router = useRouter(); const colorScheme = useColorScheme(); @@ -49,30 +43,30 @@ export default function PermissionsScreen() { const [healthKitStatus, setHealthKitStatus] = useState('not_determined'); const [throneStatus, setThroneStatus] = useState('not_determined'); + const [clinicalStatus, setClinicalStatus] = useState('not_determined'); + const [clinicalAvailable, setClinicalAvailable] = useState(false); const [isLoading, setIsLoading] = useState(false); // HealthKit is required, Throne is optional const canContinue = healthKitStatus === 'granted' || Platform.OS !== 'ios'; useEffect(() => { - // Check initial status + let cancelled = false; async function checkStatus() { - if (HealthKitService?.isAvailable?.()) { - // Check if we already have permission - // Note: HealthKit doesn't expose a way to check status directly - // We'll just start fresh - } - const thronePermission = await ThroneService.getPermissionStatus(); - setThroneStatus(thronePermission); - } + if (!cancelled) setThroneStatus(thronePermission); + if (Platform.OS === 'ios') { + const available = areClinicalRecordsAvailable(); + if (!cancelled) setClinicalAvailable(available); + } + } checkStatus(); + return () => { cancelled = true; }; }, []); const handleHealthKitRequest = useCallback(async () => { - if (!HealthKitService?.isAvailable?.()) { - // Not on iOS or HealthKit not available + if (Platform.OS !== 'ios') { Alert.alert( 'HealthKit Not Available', 'HealthKit is only available on iOS devices. For demo purposes, you can continue.', @@ -85,20 +79,10 @@ export default function PermissionsScreen() { setHealthKitStatus('loading'); try { - // Import sample types - const { SampleType } = require('@spezivibe/healthkit'); + const result = await requestHealthPermissions(); + setHealthKitStatus(result.success ? 'granted' : 'denied'); - const granted = await HealthKitService.requestAuthorization([ - SampleType.stepCount, - SampleType.heartRate, - SampleType.sleepAnalysis, - SampleType.activeEnergyBurned, - SampleType.distanceWalkingRunning, - ]); - - setHealthKitStatus(granted ? 'granted' : 'denied'); - - if (!granted) { + if (!result.success) { Alert.alert( 'Permission Required', 'HealthKit access is required for the study. Please enable it in Settings.', @@ -108,21 +92,38 @@ export default function PermissionsScreen() { ] ); } - } catch (error) { - console.error('HealthKit error:', error); + } catch { setHealthKitStatus('denied'); Alert.alert('Error', 'Failed to request HealthKit permissions. Please try again.'); } }, []); + const handleClinicalRequest = useCallback(async () => { + if (Platform.OS !== 'ios') { + setClinicalStatus('granted'); + return; + } + + setClinicalStatus('loading'); + try { + const result = await requestClinicalPermissions(); + setClinicalStatus(result.success ? 'granted' : 'denied'); + } catch { + setClinicalStatus('denied'); + Alert.alert('Error', 'Failed to request clinical records permissions.'); + } + }, []); + + const handleClinicalSkip = useCallback(() => { + setClinicalStatus('skipped'); + }, []); + const handleThroneRequest = useCallback(async () => { setThroneStatus('loading'); - try { const status = await ThroneService.requestPermission(); setThroneStatus(status); - } catch (error) { - console.error('Throne error:', error); + } catch { setThroneStatus('denied'); } }, []); @@ -134,27 +135,24 @@ export default function PermissionsScreen() { const handleContinue = async () => { setIsLoading(true); - try { - // Save permission status await OnboardingService.updateData({ permissions: { healthKit: healthKitStatus as 'granted' | 'denied' | 'not_determined', + clinicalRecords: clinicalStatus as 'granted' | 'denied' | 'not_determined' | 'skipped', throne: throneStatus as 'granted' | 'denied' | 'not_determined' | 'skipped', }, }); - - await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY); - router.push('/(onboarding)/baseline-survey' as Href); + await OnboardingService.goToStep(OnboardingStep.HEALTH_DATA_TEST); + router.push('/(onboarding)/health-data-test' as Href); } finally { setIsLoading(false); } }; - // Dev-only handler that bypasses permission requirements const handleDevContinue = async () => { - await OnboardingService.goToStep(OnboardingStep.BASELINE_SURVEY); - router.push('/(onboarding)/baseline-survey' as Href); + await OnboardingService.goToStep(OnboardingStep.HEALTH_DATA_TEST); + router.push('/(onboarding)/health-data-test' as Href); }; return ( @@ -179,7 +177,6 @@ export default function PermissionsScreen() { Your data is encrypted and only used for research purposes. - {/* HealthKit Permission */} - {/* Throne Permission */} + + - {/* Info box */} - + { + setHealthKitStatus('not_determined'); + setThroneStatus('not_determined'); + Alert.alert( + 'Permissions Reset', + 'App permission state cleared. Tap "Request HealthKit Access" to re-request.\n\nNote: iOS only shows the system dialog once per install. To fully reset, delete and reinstall the app.', + ); + }, + }, + ]} + /> ); } const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - paddingTop: Spacing.sm, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: Spacing.screenHorizontal, - }, + container: { flex: 1 }, + header: { paddingTop: Spacing.sm }, + scrollView: { flex: 1 }, + scrollContent: { padding: Spacing.screenHorizontal }, titleContainer: { flexDirection: 'row', alignItems: 'center', @@ -255,10 +270,7 @@ const styles = StyleSheet.create({ marginBottom: Spacing.sm, marginTop: Spacing.md, }, - title: { - fontSize: 28, - fontWeight: '700', - }, + title: { fontSize: 28, fontWeight: '700' }, description: { fontSize: 16, lineHeight: 23, @@ -273,11 +285,7 @@ const styles = StyleSheet.create({ gap: 12, marginTop: Spacing.sm, }, - infoText: { - fontSize: 14, - lineHeight: 20, - flex: 1, - }, + infoText: { fontSize: 14, lineHeight: 20, flex: 1 }, footer: { padding: Spacing.md, paddingBottom: Spacing.lg, diff --git a/homeflow/app/(onboarding)/welcome.tsx b/homeflow/app/(onboarding)/welcome.tsx index 788779d..6c0c252 100644 --- a/homeflow/app/(onboarding)/welcome.tsx +++ b/homeflow/app/(onboarding)/welcome.tsx @@ -13,7 +13,7 @@ import { useColorScheme, Animated, } from 'react-native'; -import { useRouter, Href } from 'expo-router'; +import { useRouter } from 'expo-router'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Colors, StanfordColors, Spacing } from '@/constants/theme'; import { STUDY_INFO, OnboardingStep } from '@/lib/constants'; @@ -55,8 +55,9 @@ export default function WelcomeScreen() { }, [fadeAnim, slideAnim, iconScale]); const handleContinue = async () => { - await OnboardingService.goToStep(OnboardingStep.CHAT); - router.push('/(onboarding)/chat' as Href); + // Advance to the next step in the onboarding flow (chat/eligibility) + await OnboardingService.nextStep(); + router.replace('/(onboarding)/chat'); }; return ( diff --git a/homeflow/app/(tabs)/_layout.tsx b/homeflow/app/(tabs)/_layout.tsx index 2e2282c..a1e6f03 100644 --- a/homeflow/app/(tabs)/_layout.tsx +++ b/homeflow/app/(tabs)/_layout.tsx @@ -3,61 +3,69 @@ import React from 'react'; import { HapticTab } from '@/components/haptic-tab'; import { IconSymbol } from '@/components/ui/icon-symbol'; -import { Colors } from '@/constants/theme'; import { useColorScheme } from '@/hooks/use-color-scheme'; export default function TabLayout() { const colorScheme = useColorScheme(); + const isDark = colorScheme === 'dark'; + + const activeTint = isDark ? '#C8D6E5' : '#2C3E50'; + const inactiveTint = isDark ? '#5A6070' : '#8E8E93'; + const tabBarBg = isDark ? '#0A0E1A' : '#F0F2F8'; + const tabBarBorder = isDark ? '#1A1E2E' : '#D8DAE2'; return ( , + tabBarIcon: ({ color }) => , }} /> , + title: 'Voiding', + tabBarIcon: ({ color }) => , }} /> , + title: 'Health', + tabBarIcon: ({ color }) => , }} /> , - }} - /> - , + title: 'Chat Helper', + tabBarIcon: ({ color }) => , }} /> , + title: 'Profile', + tabBarIcon: ({ color }) => , }} /> + + {/* Hide old tabs that still have files — prevents expo-router from auto-adding them */} + + + ); } diff --git a/homeflow/app/(tabs)/chat.tsx b/homeflow/app/(tabs)/chat.tsx index 0f4a7da..e70e036 100644 --- a/homeflow/app/(tabs)/chat.tsx +++ b/homeflow/app/(tabs)/chat.tsx @@ -1,50 +1,258 @@ -import React from 'react'; -import { View, Text, StyleSheet, useColorScheme } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { ChatView, defaultLightChatTheme, defaultDarkChatTheme } from '@spezivibe/chat'; +import React, { useRef, useEffect } from 'react'; +import { + View, + Text, + FlatList, + TouchableOpacity, + StyleSheet, + useColorScheme, + ScrollView, + Platform, + KeyboardAvoidingView, + Keyboard, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { + MessageBubble, + MessageInput, + mergeChatTheme, + defaultLightChatTheme, + defaultDarkChatTheme, +} from '@spezivibe/chat'; +import type { ChatMessage, ChatTheme, ChatProvider } from '@spezivibe/chat'; +import { useConciergeChat } from '@/lib/chat/useConciergeChat'; +import type { QuickAction } from '@/lib/chat/chatHelperPlaybook'; const OPENAI_API_KEY = process.env.EXPO_PUBLIC_OPENAI_API_KEY || ''; +const LIGHT_THEME = mergeChatTheme( + { + colors: { + background: '#F0F2F8', + assistantBubble: '#FFFFFF', + assistantBubbleText: '#2C3E50', + userBubble: '#8C1515', + userBubbleText: '#FFFFFF', + inputBackground: '#FFFFFF', + inputBorder: '#E5E7EE', + inputText: '#2C3E50', + placeholderText: '#7A7F8E', + sendButton: '#8C1515', + sendButtonDisabled: '#C7C7CC', + }, + }, + defaultLightChatTheme, +); + +const DARK_THEME = mergeChatTheme( + { + colors: { + background: '#0A0E1A', + assistantBubble: '#141828', + assistantBubbleText: '#C8D6E5', + userBubble: '#8C1515', + userBubbleText: '#FFFFFF', + inputBackground: '#141828', + inputBorder: '#1E2236', + inputText: '#C8D6E5', + placeholderText: '#6B7394', + sendButton: '#B83A4B', + sendButtonDisabled: '#3A3E50', + }, + }, + defaultDarkChatTheme, +); + export default function ChatScreen() { const colorScheme = useColorScheme(); - const theme = colorScheme === 'dark' ? defaultDarkChatTheme : defaultLightChatTheme; + const isDark = colorScheme === 'dark'; + const theme: ChatTheme = isDark ? DARK_THEME : LIGHT_THEME; + const insets = useSafeAreaInsets(); + + const provider: ChatProvider | null = OPENAI_API_KEY + ? { type: 'openai', apiKey: OPENAI_API_KEY } + : null; + + const { + messages, + isLoading, + isAnimating, + input, + setInput, + sendMessage, + startFlow, + activeCheckpoint, + quickActions, + handleStop, + } = useConciergeChat(provider); + + const flatListRef = useRef>(null); + + // Dismiss keyboard when quick actions reappear + useEffect(() => { + if (quickActions) { + Keyboard.dismiss(); + } + }, [quickActions]); + + // Auto-scroll when messages change + useEffect(() => { + if (messages.length > 0) { + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 50); + } + }, [messages]); + + const handleSend = () => { + if (!input.trim()) return; + sendMessage(input); + }; + + const handleQuickAction = (action: QuickAction) => { + if (action.comingSoon) { + sendMessage('Throne setup'); + return; + } + if (action.flowId) { + startFlow(action.flowId); + } + }; + + const handleYesNo = (answer: 'Yes' | 'No') => { + sendMessage(answer); + }; + + // Find the last assistant message to know where to show Yes/No buttons + const lastAssistantIndex = (() => { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'assistant') return i; + } + return -1; + })(); + + const renderMessage = ({ item, index }: { item: ChatMessage; index: number }) => { + const showButtons = + activeCheckpoint?.type === 'YES_NO' && + index === lastAssistantIndex && + item.role === 'assistant' && + !isLoading && + !isAnimating; - if (!OPENAI_API_KEY) { return ( - - - - API Key Missing - - - Set EXPO_PUBLIC_OPENAI_API_KEY in your .env file - - - + + + {showButtons && ( + + handleYesNo('Yes')} + activeOpacity={0.7} + > + + Yes + + + handleYesNo('No')} + activeOpacity={0.7} + > + + No + + + + )} + ); + }; + + // API key missing - show error but note playbook flows still work + if (!OPENAI_API_KEY) { + // Still render the full chat - playbook flows work without API key } return ( - - - - Welcome to Chat - - - Ask me anything - - - } - /> + + + item.id} + renderItem={renderMessage} + contentContainerStyle={styles.messageList} + showsVerticalScrollIndicator={false} + /> + + {quickActions && ( + + {quickActions.map((action) => ( + handleQuickAction(action)} + activeOpacity={0.7} + > + + {action.label} + {action.comingSoon ? ' (coming soon)' : ''} + + + ))} + + )} + + + ); } @@ -53,23 +261,83 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - centeredContent: { + flex: { flex: 1, - justifyContent: 'center', - alignItems: 'center', - padding: 24, - }, - emptyState: { - alignItems: 'center', - padding: 24, - }, - emptyTitle: { - fontSize: 20, - fontWeight: '600', - marginBottom: 8, - }, - emptySubtitle: { - fontSize: 16, - textAlign: 'center', + }, + messageList: { + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 8, + }, + + // Yes/No buttons + yesNoRow: { + flexDirection: 'row', + gap: 10, + marginTop: 8, + marginBottom: 12, + }, + yesNoButton: { + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 10, + }, + yesNoButtonLight: { + backgroundColor: '#ECF4F0', + }, + yesNoButtonDark: { + backgroundColor: '#0F1E1A', + }, + yesNoText: { + fontSize: 15, + fontWeight: '500', + lineHeight: 20, + }, + yesNoTextLight: { + color: '#2C3E50', + }, + yesNoTextDark: { + color: '#C8D6E5', + }, + + // Quick action chips + quickActionsScroll: { + maxHeight: 52, + }, + quickActionsContainer: { + paddingHorizontal: 16, + paddingVertical: 8, + gap: 8, + }, + chip: { + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 16, + borderWidth: 1, + }, + chipLight: { + backgroundColor: '#FFFFFF', + borderColor: '#E5E7EE', + }, + chipDark: { + backgroundColor: '#141828', + borderColor: '#1E2236', + }, + chipComingSoon: { + opacity: 0.5, + }, + chipText: { + fontSize: 14, + fontWeight: '500', + lineHeight: 18, + }, + chipTextLight: { + color: '#2C3E50', + }, + chipTextDark: { + color: '#C8D6E5', + }, + chipTextComingSoon: { + fontStyle: 'italic', }, }); diff --git a/homeflow/app/(tabs)/health.tsx b/homeflow/app/(tabs)/health.tsx index 0bec541..c2cd793 100644 --- a/homeflow/app/(tabs)/health.tsx +++ b/homeflow/app/(tabs)/health.tsx @@ -1,42 +1,103 @@ import React from 'react'; -import { StyleSheet, useColorScheme, Platform } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; import { - HealthKitProvider, - HealthView, - ExpoGoFallback, - defaultLightHealthTheme, - defaultDarkHealthTheme, -} from '@spezivibe/healthkit'; -import { healthKitConfig } from '@/lib/healthkit-config'; + StyleSheet, + useColorScheme, + Platform, + View, + Text, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useHealthSummary } from '@/hooks/use-health-summary'; +import { SleepSection } from '@/components/health/SleepSection'; +import { ActivitySection } from '@/components/health/ActivitySection'; +import { VitalsSection } from '@/components/health/VitalsSection'; export default function HealthScreen() { - const colorScheme = useColorScheme(); - const theme = - colorScheme === 'dark' ? defaultDarkHealthTheme : defaultLightHealthTheme; + const isDark = useColorScheme() === 'dark'; - // HealthKit is iOS only if (Platform.OS !== 'ios') { return ( - - + + + + Health data is available on iPhone + + + + ); + } + + return ; +} + +function HealthContent() { + const isDark = useColorScheme() === 'dark'; + const { summary, isLoading, error } = useHealthSummary(); + + if (isLoading) { + return ( + + + + + + ); + } + + if (error) { + return ( + + + + {error} + + + + ); + } + + if (!summary) { + return ( + + + + No health data available yet. Wear your Apple Watch today and check back later. + + ); } return ( - - } + + - - + + {summary.dateLabel} + + + {summary.greeting} + + + + + {summary.sleep && } + + + + {summary.activity && } + + + + {summary.vitals && } + + + ); } @@ -44,5 +105,60 @@ export default function HealthScreen() { const styles = StyleSheet.create({ container: { flex: 1, + backgroundColor: '#F0F2F8', + }, + containerDark: { + backgroundColor: '#0A0E1A', + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 20, + paddingTop: 16, + }, + dateLabel: { + fontSize: 14, + color: '#7A7F8E', + textTransform: 'uppercase', + letterSpacing: 1, + fontWeight: '500', + }, + dateLabelDark: { + color: '#8B92A8', + }, + greeting: { + fontSize: 28, + fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + marginTop: 4, + }, + greetingDark: { + color: '#C8D6E5', + }, + emptyText: { + fontSize: 16, + color: '#7A7F8E', + textAlign: 'center', + lineHeight: 24, + }, + emptyTextDark: { + color: '#8B92A8', + }, + spacerLarge: { + height: 24, + }, + spacerMedium: { + height: 16, + }, + spacerBottom: { + height: 32, }, }); diff --git a/homeflow/app/(tabs)/index.tsx b/homeflow/app/(tabs)/index.tsx index 3f80d4a..8dc514b 100644 --- a/homeflow/app/(tabs)/index.tsx +++ b/homeflow/app/(tabs)/index.tsx @@ -1,21 +1,35 @@ -import { Image } from 'expo-image'; -import { StyleSheet, Pressable, Alert } from 'react-native'; -import { useRouter, Href } from 'expo-router'; -import { HelloWave } from '@/components/hello-wave'; -import ParallaxScrollView from '@/components/parallax-scroll-view'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; +import React, { useState } from 'react'; +import { + StyleSheet, + View, + Text, + ScrollView, + TouchableOpacity, + Alert, + useColorScheme, + Modal, + Pressable, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { IconSymbol } from '@/components/ui/icon-symbol'; import { OnboardingService } from '@/lib/services/onboarding-service'; +import { notifyOnboardingComplete } from '@/hooks/use-onboarding-status'; +import { useSurgeryDate } from '@/hooks/use-surgery-date'; +import { useWatchUsage } from '@/hooks/use-watch-usage'; +import { SurgeryCompleteModal } from '@/components/home/SurgeryCompleteModal'; export default function HomeScreen() { - const router = useRouter(); + const isDark = useColorScheme() === 'dark'; + const surgery = useSurgeryDate(); + const watch = useWatchUsage(); + const [showSurgeryModal, setShowSurgeryModal] = useState(false); + const [showDevSheet, setShowDevSheet] = useState(false); + const [watchDismissed, setWatchDismissed] = useState(false); - // TEMPORARY: Development-only function to reset onboarding - // TODO: Remove this before production release - const handleResetOnboarding = async () => { + const handleResetOnboarding = () => { Alert.alert( 'Reset Onboarding?', - 'This will clear all onboarding progress and restart from the beginning. This feature is for development only.', + 'This will clear all onboarding progress and restart from the beginning.', [ { text: 'Cancel', style: 'cancel' }, { @@ -23,101 +37,579 @@ export default function HomeScreen() { style: 'destructive', onPress: async () => { try { - // Clear all onboarding data await OnboardingService.reset(); - // Navigate back to onboarding flow - router.replace('/(onboarding)' as Href); + notifyOnboardingComplete(); } catch (error) { console.error('Error resetting onboarding:', error); Alert.alert('Error', 'Failed to reset onboarding'); } }, }, - ] + ], ); }; + const showWatchReminder = !watch.isLoading && !watch.watchWornRecently && !watchDismissed; + return ( - - }> - - Welcome to HomeFlow! - - - - - Study Dashboard - - Track your BPH symptoms, medications, and progress throughout the study. - - - - {/* TEMPORARY: Development-only reset button */} - {/* TODO: Remove this entire section before production release */} - - - ⚠️ Developer Tools - - - Temporary features for testing - remove before production - - - - 🔄 Reset Onboarding (Dev Only) - - - - This will clear all onboarding data and restart the flow - - - + + + {/* Header */} + + + + {new Date().toLocaleDateString('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + })} + + + Welcome to HomeFlow + + + {__DEV__ && ( + setShowDevSheet(true)} + activeOpacity={0.7} + > + + Dev + + )} + + + {/* Surgery Date Card */} + + + + + Surgery date + + {surgery.isPlaceholder && __DEV__ && ( + + Placeholder + + )} + + {surgery.isLoading ? ( + Loading... + ) : ( + <> + + {surgery.dateLabel} + + {surgery.date && !surgery.hasPassed && ( + + {daysUntil(surgery.date)} + + )} + {surgery.hasPassed && ( + + Surgery completed — tracking recovery + + )} + + )} + + + {/* Study Timeline Card */} + {!surgery.isLoading && ( + + + + + Study timeline + + {surgery.isPlaceholder && __DEV__ && ( + + Placeholder + + )} + + + + + Start + + + {surgery.studyStartLabel} + + + + + + End + + + {surgery.studyEndLabel} + + + + + )} + + {/* Watch Reminder */} + {showWatchReminder && ( + + + + + + Wear your Apple Watch today + + + We use watch data to track your activity and sleep patterns. + + + setWatchDismissed(true)} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + > + + + + + )} + + {/* Watch all set */} + {!watch.isLoading && watch.watchWornRecently && ( + + + + Apple Watch data is syncing — all set + + + )} + + {/* Study info */} + + + Your study + + + Track your BPH symptoms, voiding patterns, and recovery progress + throughout the study. Your daily data helps your care team + understand your health patterns. + + + + + + + {/* Surgery Complete Modal */} + setShowSurgeryModal(false)} + /> + + {/* Dev Tools Sheet */} + {__DEV__ && ( + setShowDevSheet(false)} + > + setShowDevSheet(false)}> + {}} + > + + + Developer Tools + + + { + setShowDevSheet(false); + setTimeout(() => setShowSurgeryModal(true), 300); + }} + activeOpacity={0.7} + > + + + Trigger Surgery Complete + + + + { + setShowDevSheet(false); + setTimeout(handleResetOnboarding, 300); + }} + activeOpacity={0.7} + > + + + Reset Onboarding + + + + setShowDevSheet(false)} + activeOpacity={0.7} + > + + Close + + + + + + )} + ); } +function daysUntil(dateStr: string): string { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const target = new Date(dateStr + 'T00:00:00'); + const diff = Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + if (diff === 0) return 'Scheduled for today'; + if (diff === 1) return 'Tomorrow'; + if (diff < 0) return ''; + return `${diff} days from now`; +} + const styles = StyleSheet.create({ - titleContainer: { + container: { + flex: 1, + backgroundColor: '#F0F2F8', + }, + containerDark: { + backgroundColor: '#0A0E1A', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 20, + paddingTop: 16, + }, + + // Header + headerRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 24, + }, + headerText: { + flex: 1, + }, + dateLabel: { + fontSize: 14, + color: '#7A7F8E', + textTransform: 'uppercase', + letterSpacing: 1, + fontWeight: '500', + }, + dateLabelDark: { + color: '#8B92A8', + }, + greeting: { + fontSize: 28, + fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + marginTop: 4, + }, + greetingDark: { + color: '#C8D6E5', + }, + + // Dev pill + devPill: { flexDirection: 'row', alignItems: 'center', - gap: 8, + gap: 4, + backgroundColor: '#E0E2EA', + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 14, + marginTop: 4, }, - stepContainer: { - gap: 8, + devPillDark: { + backgroundColor: '#1A1E2E', + }, + devPillText: { + fontSize: 12, + fontWeight: '600', + color: '#7A7F8E', + }, + devPillTextDark: { + color: '#8B92A8', + }, + + // Cards + card: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, marginBottom: 16, }, - logo: { - height: 200, - width: 200, - position: 'absolute', - bottom: -20, - left: -20, + cardDark: { + backgroundColor: '#141828', }, - // TEMPORARY: Dev section styles - remove before production - devSection: { - marginTop: 32, - padding: 16, - borderRadius: 12, - borderWidth: 2, - borderColor: '#FF9500', - backgroundColor: 'rgba(255, 149, 0, 0.1)', + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 10, + }, + cardLabel: { + fontSize: 13, + color: '#7B8CDE', + fontWeight: '500', + }, + cardLabelDark: { + color: '#A0A8D0', + }, + cardValue: { + fontSize: 22, + fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + }, + cardValueDark: { + color: '#D4D8E8', }, - devButton: { - backgroundColor: '#FF9500', + cardSubtext: { + fontSize: 14, + color: '#7A7F8E', + marginTop: 4, + }, + cardSubtextDark: { + color: '#8B92A8', + }, + placeholderBadge: { + backgroundColor: '#E8E0F0', + paddingHorizontal: 8, + paddingVertical: 2, borderRadius: 8, - padding: 14, + marginLeft: 'auto', + }, + placeholderBadgeDark: { + backgroundColor: '#1E1828', + }, + placeholderBadgeText: { + fontSize: 10, + fontWeight: '600', + color: '#9B8AB8', + }, + + // Study timeline + timelineRow: { + flexDirection: 'row', alignItems: 'center', }, - devButtonText: { - color: '#FFFFFF', + timelineItem: { + flex: 1, + }, + timelineLabel: { + fontSize: 12, + fontWeight: '500', + color: '#7A7F8E', + textTransform: 'uppercase', + letterSpacing: 0.5, + marginBottom: 2, + }, + timelineLabelDark: { + color: '#6B7394', + }, + timelineValue: { + fontSize: 16, + fontWeight: '600', + color: '#2C3E50', + }, + timelineValueDark: { + color: '#D4D8E8', + }, + timelineDivider: { + width: 1, + height: 32, + backgroundColor: '#D8DAE2', + marginHorizontal: 16, + }, + timelineDividerDark: { + backgroundColor: '#1E2236', + }, + + // Watch reminder + reminderCard: { + backgroundColor: '#ECF4F0', + borderRadius: 16, + padding: 16, + marginBottom: 16, + }, + reminderCardDark: { + backgroundColor: '#0F1E1A', + }, + reminderContent: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + }, + reminderTextContainer: { + flex: 1, + }, + reminderTitle: { + fontSize: 15, + fontWeight: '600', + color: '#2A3E36', + marginBottom: 2, + }, + reminderTitleDark: { + color: '#CDDDD6', + }, + reminderBody: { + fontSize: 14, + color: '#5E7A70', + lineHeight: 20, + }, + reminderBodyDark: { + color: '#8EAAA0', + }, + + // All set + allSetCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: '#ECF4F0', + borderRadius: 16, + padding: 16, + marginBottom: 16, + }, + allSetCardDark: { + backgroundColor: '#0F1E1A', + }, + allSetText: { fontSize: 15, + color: '#2A3E36', + fontWeight: '500', + }, + allSetTextDark: { + color: '#CDDDD6', + }, + + // Study section + sectionTitle: { + fontSize: 18, fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + marginBottom: 8, + }, + sectionTitleDark: { + color: '#C8D6E5', + }, + studyBody: { + fontSize: 15, + color: '#5E6B7A', + lineHeight: 22, + }, + studyBodyDark: { + color: '#8B92A8', + }, + + // Dev tools sheet + sheetOverlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.3)', + }, + sheetContent: { + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 24, + paddingBottom: 40, + }, + sheetContentDark: { + backgroundColor: '#1A1E2E', + }, + sheetHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#D0D0D0', + alignSelf: 'center', + marginBottom: 20, + }, + sheetTitle: { + fontSize: 16, + fontWeight: '600', + color: '#7A7F8E', + marginBottom: 16, + textAlign: 'center', + }, + sheetTitleDark: { + color: '#8B92A8', + }, + sheetButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + backgroundColor: '#F0F2F8', + padding: 16, + borderRadius: 12, + marginBottom: 10, + }, + sheetButtonDark: { + backgroundColor: '#0F1320', + }, + sheetButtonText: { + fontSize: 15, + fontWeight: '500', + color: '#2C3E50', + }, + sheetButtonTextDark: { + color: '#C8D6E5', + }, + sheetCancel: { + alignItems: 'center', + padding: 14, + marginTop: 4, + }, + sheetCancelDark: {}, + sheetCancelText: { + fontSize: 15, + fontWeight: '500', + color: '#8E8E93', + }, + sheetCancelTextDark: { + color: '#6B7394', }, }); diff --git a/homeflow/app/(tabs)/profile.tsx b/homeflow/app/(tabs)/profile.tsx new file mode 100644 index 0000000..b22f938 --- /dev/null +++ b/homeflow/app/(tabs)/profile.tsx @@ -0,0 +1,491 @@ +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + useColorScheme, + Modal, + Pressable, + Linking, +} from 'react-native'; +import { useRouter, Href } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { + CONSENT_PROFILE_SUMMARY, + DATA_PERMISSIONS_SUMMARY, + STUDY_COORDINATOR, +} from '@/lib/consent/consent-document'; + +export default function ProfileScreen() { + const isDark = useColorScheme() === 'dark'; + const router = useRouter(); + const [showConsentModal, setShowConsentModal] = useState(false); + const [showPermissionsModal, setShowPermissionsModal] = useState(false); + + return ( + + + + Profile + + + {/* 1. Account */} + + + + Account + + + Account details will appear here once sign-in is enabled. + + + + {/* 2. Study Consent & Data Permissions — compact tappable rows */} + + setShowConsentModal(true)} + activeOpacity={0.7} + > + + + + Study consent + + + + + + + + setShowPermissionsModal(true)} + activeOpacity={0.7} + > + + + + Data permissions + + + + + + + + router.push('/consent-viewer' as Href)} + activeOpacity={0.7} + > + + + + View full consent document + + + + + + + {/* 4. Contact / Support */} + + + + + Contact for study questions + + + + {STUDY_COORDINATOR.name} + + + {STUDY_COORDINATOR.role} + + + + Linking.openURL(`mailto:${STUDY_COORDINATOR.email}`)} + activeOpacity={0.7} + > + + + {STUDY_COORDINATOR.email} + + + + Linking.openURL(`tel:${STUDY_COORDINATOR.phone}`)} + activeOpacity={0.7} + > + + + {STUDY_COORDINATOR.phone} + + + + + + + + + {/* Study Consent summary modal */} + setShowConsentModal(false)} + > + setShowConsentModal(false)} + > + {}} + > + + + + Study consent + + + {CONSENT_PROFILE_SUMMARY} + + setShowConsentModal(false)} + activeOpacity={0.7} + > + + Got it + + + + + + + {/* Data Permissions modal */} + setShowPermissionsModal(false)} + > + setShowPermissionsModal(false)} + > + {}} + > + + + + Data permissions + + + What this app can access: + + {DATA_PERMISSIONS_SUMMARY.map((item, index) => ( + + + + {item} + + + ))} + setShowPermissionsModal(false)} + activeOpacity={0.7} + > + + Got it + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F0F2F8', + }, + containerDark: { + backgroundColor: '#0A0E1A', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 20, + paddingTop: 16, + }, + screenTitle: { + fontSize: 28, + fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + marginBottom: 24, + }, + screenTitleDark: { + color: '#C8D6E5', + }, + + // Cards + card: { + backgroundColor: '#FFFFFF', + borderRadius: 16, + padding: 20, + marginBottom: 16, + }, + cardDark: { + backgroundColor: '#141828', + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 12, + }, + cardLabel: { + fontSize: 13, + fontWeight: '500', + color: '#7A7F8E', + }, + cardLabelDark: { + color: '#8B92A8', + }, + + // Account placeholder + placeholderText: { + fontSize: 15, + color: '#8E8E93', + lineHeight: 22, + fontStyle: 'italic', + }, + placeholderTextDark: { + color: '#6B7394', + }, + + // Tappable row buttons + rowButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + }, + rowLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + rowLabel: { + fontSize: 15, + fontWeight: '500', + }, + rowDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: '#E5E7EE', + }, + rowDividerDark: { + backgroundColor: '#1E2236', + }, + bulletRow: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 8, + paddingRight: 4, + }, + bullet: { + width: 6, + height: 6, + borderRadius: 3, + backgroundColor: '#C7C7CC', + marginTop: 7, + marginRight: 10, + }, + bulletDark: { + backgroundColor: '#3A3E50', + }, + bulletText: { + flex: 1, + fontSize: 15, + color: '#5E6B7A', + lineHeight: 22, + }, + bulletTextDark: { + color: '#8B92A8', + }, + + // Contact + contactName: { + fontSize: 18, + fontWeight: '600', + color: '#2C3E50', + }, + contactNameDark: { + color: '#D4D8E8', + }, + contactRole: { + fontSize: 14, + color: '#7A7F8E', + marginTop: 2, + marginBottom: 14, + }, + contactRoleDark: { + color: '#6B7394', + }, + contactActions: { + gap: 8, + }, + contactButton: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: '#F5EEEF', + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 10, + }, + contactButtonDark: { + backgroundColor: '#1E1318', + }, + contactButtonText: { + fontSize: 14, + color: '#5E6B7A', + }, + contactButtonTextDark: { + color: '#A8868C', + }, + + // Modal + modalOverlay: { + flex: 1, + justifyContent: 'flex-end', + backgroundColor: 'rgba(0,0,0,0.3)', + }, + modalContent: { + backgroundColor: '#FFFFFF', + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + padding: 24, + paddingBottom: 40, + }, + modalContentDark: { + backgroundColor: '#1A1E2E', + }, + modalHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: '#D0D0D0', + alignSelf: 'center', + marginBottom: 20, + }, + modalTitle: { + fontSize: 18, + fontWeight: '600', + color: '#2C3E50', + textAlign: 'center', + marginBottom: 12, + }, + modalTitleDark: { + color: '#C8D6E5', + }, + modalBody: { + fontSize: 15, + color: '#5E6B7A', + lineHeight: 22, + textAlign: 'center', + marginBottom: 24, + }, + modalBodyDark: { + color: '#8B92A8', + }, + modalSubhead: { + fontSize: 13, + fontWeight: '500', + color: '#7A7F8E', + marginBottom: 12, + textAlign: 'left', + }, + modalSubheadDark: { + color: '#6B7394', + }, + modalButton: { + backgroundColor: '#5A9E87', + paddingVertical: 14, + borderRadius: 12, + alignItems: 'center', + }, + modalButtonDark: { + backgroundColor: '#3D7A65', + }, + modalButtonText: { + fontSize: 16, + fontWeight: '600', + color: '#FFFFFF', + }, + modalButtonTextDark: { + color: '#E8F0EC', + }, +}); diff --git a/homeflow/app/(tabs)/voiding.tsx b/homeflow/app/(tabs)/voiding.tsx new file mode 100644 index 0000000..a4d8d98 --- /dev/null +++ b/homeflow/app/(tabs)/voiding.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { View, Text, StyleSheet, useColorScheme } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +export default function VoidingScreen() { + const isDark = useColorScheme() === 'dark'; + + return ( + + + Voiding + + Uroflow tracking will appear here once your Throne device is connected. + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F0F2F8', + }, + containerDark: { + backgroundColor: '#0A0E1A', + }, + centered: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + title: { + fontSize: 22, + fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + marginBottom: 12, + }, + titleDark: { + color: '#C8D6E5', + }, + subtitle: { + fontSize: 16, + color: '#7A7F8E', + textAlign: 'center', + lineHeight: 24, + }, + subtitleDark: { + color: '#8B92A8', + }, +}); diff --git a/homeflow/app/_layout.tsx b/homeflow/app/_layout.tsx index 2939fa0..6770b72 100644 --- a/homeflow/app/_layout.tsx +++ b/homeflow/app/_layout.tsx @@ -54,6 +54,10 @@ function RootLayoutNav() { name="modal" options={{ presentation: 'modal', title: 'Modal', headerShown: true }} /> + {/* Index route for initial redirect */} + + + + {CONSENT_DOCUMENT.title} + + + {CONSENT_DOCUMENT.studyName} — Version {CONSENT_DOCUMENT.version} + + + router.back()} + hitSlop={{ top: 12, bottom: 12, left: 12, right: 12 }} + > + + + + + + {CONSENT_DOCUMENT.sections.map((section) => ( + + + {section.title} + + + {section.content} + + + ))} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F0F2F8', + }, + containerDark: { + backgroundColor: '#0A0E1A', + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#D8DAE2', + }, + headerDark: { + borderBottomColor: '#1A1E2E', + }, + headerContent: { + flex: 1, + marginRight: 16, + }, + title: { + fontSize: 20, + fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + }, + titleDark: { + color: '#C8D6E5', + }, + subtitle: { + fontSize: 13, + color: '#7A7F8E', + marginTop: 2, + }, + subtitleDark: { + color: '#6B7394', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 20, + }, + section: { + backgroundColor: '#FFFFFF', + borderRadius: 14, + padding: 18, + marginBottom: 12, + }, + sectionDark: { + backgroundColor: '#141828', + }, + sectionTitle: { + fontSize: 16, + fontWeight: '600', + color: '#2C3E50', + marginBottom: 8, + }, + sectionTitleDark: { + color: '#C8D6E5', + }, + sectionContent: { + fontSize: 15, + color: '#5E6B7A', + lineHeight: 22, + }, + sectionContentDark: { + color: '#8B92A8', + }, +}); diff --git a/homeflow/app/questionnaire/[id].tsx b/homeflow/app/questionnaire/[id].tsx index 084fd82..449c5bc 100644 --- a/homeflow/app/questionnaire/[id].tsx +++ b/homeflow/app/questionnaire/[id].tsx @@ -44,10 +44,10 @@ export default function QuestionnaireScreen() { return; } - const responses = JSON.parse(existingResponses) as Array<{ + const responses = JSON.parse(existingResponses) as { response: { id?: string; basedOn?: unknown }; metadata?: { taskId?: string }; - }>; + }[]; let updated = false; for (const record of responses) { diff --git a/homeflow/components/health/ActivitySection.tsx b/homeflow/components/health/ActivitySection.tsx new file mode 100644 index 0000000..c5492a7 --- /dev/null +++ b/homeflow/components/health/ActivitySection.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, useColorScheme } from 'react-native'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import type { ActivityInsight } from '@/lib/services/health-summary'; + +interface ActivitySectionProps { + insight: ActivityInsight; +} + +export function ActivitySection({ insight }: ActivitySectionProps) { + const [expanded, setExpanded] = useState(false); + const isDark = useColorScheme() === 'dark'; + + return ( + setExpanded(!expanded)} + style={[styles.card, isDark && styles.cardDark]} + > + + + + Activity + + + + + + {insight.headline} + + + {insight.supportingText} + + + {expanded && ( + + + Steps: {insight.steps.toLocaleString()} + + + Energy burned: {Math.round(insight.energyBurned)} kcal + + + Distance: {(insight.distance / 1000).toFixed(1)} km + + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#ECF4F0', + borderRadius: 16, + padding: 20, + }, + cardDark: { + backgroundColor: '#0F1E1A', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + sectionLabel: { + fontSize: 13, + color: '#5A9E87', + fontWeight: '500', + }, + sectionLabelDark: { + color: '#8CBCAA', + }, + headline: { + fontSize: 20, + fontWeight: '600', + color: '#2A3E36', + fontFamily: 'Georgia', + marginBottom: 4, + }, + headlineDark: { + color: '#CDDDD6', + }, + supporting: { + fontSize: 16, + color: '#5E7A70', + lineHeight: 22, + }, + supportingDark: { + color: '#8EAAA0', + }, + details: { + marginTop: 16, + }, + detailRow: { + fontSize: 15, + color: '#5E7A70', + marginTop: 8, + }, + detailRowDark: { + color: '#8EAAA0', + }, +}); diff --git a/homeflow/components/health/DurationBar.tsx b/homeflow/components/health/DurationBar.tsx new file mode 100644 index 0000000..4d841bd --- /dev/null +++ b/homeflow/components/health/DurationBar.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { View, Text, StyleSheet, useColorScheme } from 'react-native'; + +interface DurationBarProps { + fill: number; + valueLabel: string; + baselineLabel: string; +} + +export function DurationBar({ fill, valueLabel, baselineLabel }: DurationBarProps) { + const isDark = useColorScheme() === 'dark'; + const clampedFill = Math.max(0, Math.min(fill, 1)); + + return ( + + + + + + {valueLabel} + {baselineLabel} + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginTop: 12, + }, + track: { + height: 8, + backgroundColor: '#D8D8E4', + borderRadius: 4, + overflow: 'hidden', + }, + trackDark: { + backgroundColor: '#1E2236', + }, + fill: { + height: 8, + backgroundColor: '#7B8CDE', + borderRadius: 4, + }, + fillDark: { + backgroundColor: '#6878C0', + }, + labels: { + flexDirection: 'row', + justifyContent: 'space-between', + marginTop: 6, + }, + label: { + fontSize: 14, + color: '#6E7286', + }, + labelDark: { + color: '#8B92A8', + }, +}); diff --git a/homeflow/components/health/SleepSection.tsx b/homeflow/components/health/SleepSection.tsx new file mode 100644 index 0000000..079e7db --- /dev/null +++ b/homeflow/components/health/SleepSection.tsx @@ -0,0 +1,135 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, useColorScheme } from 'react-native'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import { DurationBar } from './DurationBar'; +import type { SleepInsight } from '@/lib/services/health-summary'; + +interface SleepSectionProps { + insight: SleepInsight; +} + +export function SleepSection({ insight }: SleepSectionProps) { + const [expanded, setExpanded] = useState(false); + const isDark = useColorScheme() === 'dark'; + + return ( + setExpanded(!expanded)} + style={[styles.card, isDark && styles.cardDark]} + > + + + + Sleep + + + + + + {insight.headline} + + + {insight.supportingText} + + + {expanded && ( + + + + + Sleep efficiency: {insight.efficiency}% + + + {insight.stages && ( + + + Deep: {insight.stages.deep} min + + + Core: {insight.stages.core} min + + + REM: {insight.stages.rem} min + + + Awake: {insight.stages.awake} min + + + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#EEEEF6', + borderRadius: 16, + padding: 20, + }, + cardDark: { + backgroundColor: '#141828', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + sectionLabel: { + fontSize: 13, + color: '#7B8CDE', + fontWeight: '500', + }, + sectionLabelDark: { + color: '#A0A8D0', + }, + headline: { + fontSize: 20, + fontWeight: '600', + color: '#2C3044', + fontFamily: 'Georgia', + marginBottom: 4, + }, + headlineDark: { + color: '#D4D8E8', + }, + supporting: { + fontSize: 16, + color: '#6E7286', + lineHeight: 22, + }, + supportingDark: { + color: '#8B92A8', + }, + details: { + marginTop: 16, + }, + detailRow: { + fontSize: 15, + color: '#5C6080', + marginTop: 8, + }, + detailRowDark: { + color: '#9CA3BE', + }, + stagesContainer: { + marginTop: 4, + }, +}); diff --git a/homeflow/components/health/VitalsSection.tsx b/homeflow/components/health/VitalsSection.tsx new file mode 100644 index 0000000..c1fb268 --- /dev/null +++ b/homeflow/components/health/VitalsSection.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, useColorScheme } from 'react-native'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import type { VitalsInsight } from '@/lib/services/health-summary'; + +interface VitalsSectionProps { + insight: VitalsInsight; +} + +export function VitalsSection({ insight }: VitalsSectionProps) { + const [expanded, setExpanded] = useState(false); + const isDark = useColorScheme() === 'dark'; + + return ( + setExpanded(!expanded)} + style={[styles.card, isDark && styles.cardDark]} + > + + + + Vitals + + + + + + {insight.headline} + + {insight.supportingText && ( + + {insight.supportingText} + + )} + + {expanded && insight.items.length > 0 && ( + + {insight.items.map((item, index) => ( + + {index > 0 && } + + + + {item.label} + + + {item.value} + + + + ))} + + )} + + ); +} + +const styles = StyleSheet.create({ + card: { + backgroundColor: '#F5EEEF', + borderRadius: 16, + padding: 20, + }, + cardDark: { + backgroundColor: '#1E1318', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 10, + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + }, + sectionLabel: { + fontSize: 13, + color: '#B07178', + fontWeight: '500', + }, + sectionLabelDark: { + color: '#C4949A', + }, + headline: { + fontSize: 20, + fontWeight: '600', + color: '#3E2C30', + fontFamily: 'Georgia', + marginBottom: 4, + }, + headlineDark: { + color: '#DDD0D2', + }, + supporting: { + fontSize: 16, + color: '#7A5E62', + lineHeight: 22, + }, + supportingDark: { + color: '#A8868C', + }, + details: { + marginTop: 16, + }, + vitalRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#C4949A', + marginRight: 10, + }, + dotDark: { + backgroundColor: '#7A5E62', + }, + vitalLabel: { + flex: 1, + fontSize: 15, + color: '#7A5E62', + }, + vitalLabelDark: { + color: '#A8868C', + }, + vitalValue: { + fontSize: 15, + color: '#3E2C30', + fontWeight: '500', + }, + vitalValueDark: { + color: '#DDD0D2', + }, + divider: { + height: StyleSheet.hairlineWidth, + backgroundColor: '#DDD0D2', + }, + dividerDark: { + backgroundColor: '#2E2024', + }, +}); diff --git a/homeflow/components/home/SurgeryCompleteModal.tsx b/homeflow/components/home/SurgeryCompleteModal.tsx new file mode 100644 index 0000000..dcaa281 --- /dev/null +++ b/homeflow/components/home/SurgeryCompleteModal.tsx @@ -0,0 +1,182 @@ +/** + * Surgery Complete Modal + * + * A calm, full-screen modal shown when the surgery date has passed. + * Can also be triggered via dev tools for demo purposes. + */ + +import React, { useEffect, useRef } from 'react'; +import { + Modal, + View, + Text, + StyleSheet, + TouchableOpacity, + useColorScheme, + Animated, +} from 'react-native'; +import { IconSymbol } from '@/components/ui/icon-symbol'; + +interface SurgeryCompleteModalProps { + visible: boolean; + onDismiss: () => void; +} + +export function SurgeryCompleteModal({ visible, onDismiss }: SurgeryCompleteModalProps) { + const isDark = useColorScheme() === 'dark'; + const fadeAnim = useRef(new Animated.Value(0)).current; + const scaleAnim = useRef(new Animated.Value(0.9)).current; + + useEffect(() => { + if (visible) { + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + Animated.spring(scaleAnim, { + toValue: 1, + friction: 8, + tension: 40, + useNativeDriver: true, + }), + ]).start(); + } else { + fadeAnim.setValue(0); + scaleAnim.setValue(0.9); + } + }, [visible, fadeAnim, scaleAnim]); + + return ( + + + + + + + + + Surgery complete + + + + {"You've reached an important milestone in your care journey. We'll continue tracking your recovery patterns so your care team can support you."} + + + + Your daily check-ins will now focus on recovery. + + + + + Continue + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(240, 242, 248, 0.95)', + justifyContent: 'center', + alignItems: 'center', + padding: 32, + }, + overlayDark: { + backgroundColor: 'rgba(10, 14, 26, 0.95)', + }, + content: { + width: '100%', + maxWidth: 340, + alignItems: 'center', + padding: 36, + borderRadius: 24, + backgroundColor: '#FFFFFF', + }, + contentDark: { + backgroundColor: '#1A1E2E', + }, + iconCircle: { + width: 88, + height: 88, + borderRadius: 44, + backgroundColor: '#ECF4F0', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 24, + }, + iconCircleDark: { + backgroundColor: '#0F1E1A', + }, + title: { + fontSize: 26, + fontWeight: '600', + color: '#2C3E50', + fontFamily: 'Georgia', + marginBottom: 16, + textAlign: 'center', + }, + titleDark: { + color: '#C8D6E5', + }, + body: { + fontSize: 16, + color: '#5E6B7A', + lineHeight: 24, + textAlign: 'center', + marginBottom: 12, + }, + bodyDark: { + color: '#8B92A8', + }, + subtext: { + fontSize: 14, + color: '#8B92A8', + textAlign: 'center', + marginBottom: 32, + }, + subtextDark: { + color: '#6B7394', + }, + button: { + backgroundColor: '#5A9E87', + paddingHorizontal: 48, + paddingVertical: 14, + borderRadius: 12, + }, + buttonDark: { + backgroundColor: '#3D7A65', + }, + buttonText: { + fontSize: 17, + fontWeight: '600', + color: '#FFFFFF', + }, + buttonTextDark: { + color: '#E8F0EC', + }, +}); diff --git a/homeflow/components/onboarding/DevToolBar.tsx b/homeflow/components/onboarding/DevToolBar.tsx index 227bb0e..c23f70e 100644 --- a/homeflow/components/onboarding/DevToolBar.tsx +++ b/homeflow/components/onboarding/DevToolBar.tsx @@ -18,6 +18,8 @@ const STEP_TO_PATH: Record = { [OnboardingStep.CHAT]: '/(onboarding)/chat', [OnboardingStep.CONSENT]: '/(onboarding)/consent', [OnboardingStep.PERMISSIONS]: '/(onboarding)/permissions', + [OnboardingStep.HEALTH_DATA_TEST]: '/(onboarding)/health-data-test', + [OnboardingStep.MEDICAL_HISTORY]: '/(onboarding)/medical-history', [OnboardingStep.BASELINE_SURVEY]: '/(onboarding)/baseline-survey', [OnboardingStep.COMPLETE]: '/(onboarding)/complete', }; @@ -25,9 +27,10 @@ const STEP_TO_PATH: Record = { interface DevToolBarProps { currentStep: OnboardingStep; onContinue: () => void; + extraActions?: { label: string; onPress: () => void; color?: string }[]; } -export function DevToolBar({ currentStep, onContinue }: DevToolBarProps) { +export function DevToolBar({ currentStep, onContinue, extraActions }: DevToolBarProps) { const router = useRouter(); // Only render in development @@ -88,6 +91,20 @@ export function DevToolBar({ currentStep, onContinue }: DevToolBarProps) { Continue + {extraActions && extraActions.length > 0 && ( + + {extraActions.map((action, i) => ( + + {action.label} + + ))} + + )} ); } @@ -114,6 +131,11 @@ const styles = StyleSheet.create({ flexDirection: 'row', gap: 8, }, + extraRow: { + flexDirection: 'row', + gap: 8, + marginTop: 6, + }, button: { flex: 1, height: 40, diff --git a/homeflow/components/ui/icon-symbol.tsx b/homeflow/components/ui/icon-symbol.tsx index f4b56c1..75636f4 100644 --- a/homeflow/components/ui/icon-symbol.tsx +++ b/homeflow/components/ui/icon-symbol.tsx @@ -46,6 +46,18 @@ const MAPPING = { // Documents 'doc.text.fill': 'description', + + // Health + 'moon.fill': 'nightlight', + 'figure.walk': 'directions-walk', + + // Tabs & Home + 'drop.fill': 'water-drop', + 'person.fill': 'person', + 'applewatch': 'watch', + 'wrench.fill': 'build', + 'xmark': 'close', + 'calendar.badge.clock': 'event', } satisfies Record; export type IconSymbolName = keyof typeof MAPPING; diff --git a/homeflow/hooks/use-health-summary.ts b/homeflow/hooks/use-health-summary.ts new file mode 100644 index 0000000..cdc133a --- /dev/null +++ b/homeflow/hooks/use-health-summary.ts @@ -0,0 +1,97 @@ +/** + * Health Summary Hook + * + * Fetches HealthKit data and derives a HealthSummaryDay view model + * for the Daily Check-In screen. + */ + +import { useState, useEffect, useCallback } from 'react'; +import { + getDailyActivity, + getSleep, + getVitals, + getDateRange, +} from '@/lib/services/healthkit'; +import { buildHealthSummaryDay } from '@/lib/services/health-summary'; +import type { HealthSummaryDay } from '@/lib/services/health-summary'; + +export function useHealthSummary(): { + summary: HealthSummaryDay | null; + isLoading: boolean; + error: string | null; + refresh: () => void; +} { + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshKey, setRefreshKey] = useState(0); + + const refresh = useCallback(() => { + setRefreshKey((k) => k + 1); + }, []); + + useEffect(() => { + let cancelled = false; + + async function fetchData() { + setIsLoading(true); + setError(null); + + try { + const range = getDateRange(8); // today + 7 days for baseline + const [activityData, sleepData, vitalsData] = await Promise.all([ + getDailyActivity(range), + getSleep(range), + getVitals(range), + ]); + + if (cancelled) return; + + // Today's date string + const today = new Date().toISOString().split('T')[0]; + + // Find today's data, falling back to most recent if today has none + const todayActivity = + activityData.find((d) => d.date === today) ?? + (activityData.length > 0 ? activityData[activityData.length - 1] : null); + const todaySleep = sleepData.length > 0 ? sleepData[0] : null; + const todayVitals = + vitalsData.find((d) => d.date === today) ?? + (vitalsData.length > 0 ? vitalsData[vitalsData.length - 1] : null); + + // Recent sleep for baseline (exclude the selected entry) + const recentSleep = todaySleep + ? sleepData.filter((n) => n.date !== todaySleep.date) + : sleepData.slice(1); + + const result = buildHealthSummaryDay( + today, + todaySleep, + recentSleep, + todayActivity, + todayVitals, + ); + + if (!cancelled) { + setSummary(result); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load health data'); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + fetchData(); + + return () => { + cancelled = true; + }; + }, [refreshKey]); + + return { summary, isLoading, error, refresh }; +} diff --git a/homeflow/hooks/use-onboarding-status.ts b/homeflow/hooks/use-onboarding-status.ts index 99cd519..7a52867 100644 --- a/homeflow/hooks/use-onboarding-status.ts +++ b/homeflow/hooks/use-onboarding-status.ts @@ -9,6 +9,16 @@ import { useState, useEffect, useCallback } from 'react'; import { OnboardingService } from '@/lib/services/onboarding-service'; import { OnboardingStep } from '@/lib/constants'; +/** + * Simple event emitter for onboarding status changes + */ +type StatusListener = () => void; +const statusListeners: Set = new Set(); + +export function notifyOnboardingComplete(): void { + statusListeners.forEach((listener) => listener()); +} + /** * Hook that returns onboarding completion status * Returns null while loading, true if complete, false if not @@ -28,8 +38,15 @@ export function useOnboardingStatus(): boolean | null { checkStatus(); + // Listen for status changes + const listener = () => { + checkStatus(); + }; + statusListeners.add(listener); + return () => { cancelled = true; + statusListeners.delete(listener); }; }, []); diff --git a/homeflow/hooks/use-surgery-date.ts b/homeflow/hooks/use-surgery-date.ts new file mode 100644 index 0000000..1059e3f --- /dev/null +++ b/homeflow/hooks/use-surgery-date.ts @@ -0,0 +1,153 @@ +/** + * Surgery Date Hook + * + * Reads the scheduled surgery date from onboarding medical history data. + * Falls back to a placeholder date in dev builds when no real data exists. + */ + +import { useState, useEffect } from 'react'; +import { OnboardingService } from '@/lib/services/onboarding-service'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { STORAGE_KEYS } from '@/lib/constants'; + +interface SurgeryDateInfo { + /** The surgery date string (YYYY-MM-DD) or null */ + date: string | null; + /** Human-readable label like "March 15, 2026" */ + dateLabel: string; + /** Whether the surgery date has passed */ + hasPassed: boolean; + /** Whether this is using placeholder data */ + isPlaceholder: boolean; + /** Loading state */ + isLoading: boolean; + /** Study start date (YYYY-MM-DD) — at least 1 week before surgery */ + studyStartDate: string | null; + studyStartLabel: string; + /** Study end date (YYYY-MM-DD) — 90 days after surgery */ + studyEndDate: string | null; + studyEndLabel: string; +} + +// Dev placeholder: surgery 2 weeks from now +function getPlaceholderDate(): string { + const d = new Date(); + d.setDate(d.getDate() + 14); + return d.toISOString().split('T')[0]; +} + +function addDays(dateStr: string, days: number): string { + const d = new Date(dateStr + 'T12:00:00'); + d.setDate(d.getDate() + days); + return d.toISOString().split('T')[0]; +} + +function formatDateLabel(dateStr: string): string { + const date = new Date(dateStr + 'T12:00:00'); + return date.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }); +} + +export function useSurgeryDate(): SurgeryDateInfo { + const [info, setInfo] = useState({ + date: null, + dateLabel: '', + hasPassed: false, + isPlaceholder: true, + isLoading: true, + studyStartDate: null, + studyStartLabel: '', + studyEndDate: null, + studyEndLabel: '', + }); + + useEffect(() => { + let cancelled = false; + + async function loadSurgeryDate() { + try { + // Try to get surgery date from medical history + const data = await OnboardingService.getData(); + const medHistory = data.medicalHistory; + + // Check AsyncStorage directly for a surgery date field + const stored = await AsyncStorage.getItem(STORAGE_KEYS.MEDICAL_HISTORY); + let surgeryDate: string | null = null; + + if (stored) { + try { + const parsed = JSON.parse(stored); + surgeryDate = parsed.surgeryDate ?? null; + } catch { + // ignore parse errors + } + } + + if (cancelled) return; + + const isPlaceholder = !surgeryDate; + const effectiveDate = surgeryDate ?? (__DEV__ ? getPlaceholderDate() : null); + + if (effectiveDate) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const surgDate = new Date(effectiveDate + 'T12:00:00'); + const hasPassed = surgDate <= today; + + // Study start: 7 days before surgery; Study end: 90 days after surgery + const startDate = addDays(effectiveDate, -7); + const endDate = addDays(effectiveDate, 90); + + setInfo({ + date: effectiveDate, + dateLabel: formatDateLabel(effectiveDate), + hasPassed, + isPlaceholder, + isLoading: false, + studyStartDate: startDate, + studyStartLabel: formatDateLabel(startDate), + studyEndDate: endDate, + studyEndLabel: formatDateLabel(endDate), + }); + } else { + setInfo({ + date: null, + dateLabel: 'Not scheduled', + hasPassed: false, + isPlaceholder: true, + isLoading: false, + studyStartDate: null, + studyStartLabel: 'Not scheduled', + studyEndDate: null, + studyEndLabel: 'Not scheduled', + }); + } + } catch { + if (!cancelled) { + setInfo({ + date: null, + dateLabel: 'Not scheduled', + hasPassed: false, + isPlaceholder: true, + isLoading: false, + studyStartDate: null, + studyStartLabel: 'Not scheduled', + studyEndDate: null, + studyEndLabel: 'Not scheduled', + }); + } + } + } + + loadSurgeryDate(); + + return () => { + cancelled = true; + }; + }, []); + + return info; +} diff --git a/homeflow/hooks/use-watch-usage.ts b/homeflow/hooks/use-watch-usage.ts new file mode 100644 index 0000000..661d86a --- /dev/null +++ b/homeflow/hooks/use-watch-usage.ts @@ -0,0 +1,58 @@ +/** + * Watch Usage Status Hook (Stub) + * + * Provides a clean interface for checking if the user has worn their + * Apple Watch recently. Currently stubbed — will be wired to real + * HealthKit data (e.g., checking for recent heart rate samples). + */ + +import { useState, useEffect, useCallback } from 'react'; +import { Platform } from 'react-native'; + +interface WatchUsageStatus { + /** Whether watch data has been recorded today */ + hasWatchDataToday: boolean; + /** Whether the watch was worn recently (last 24h) */ + watchWornRecently: boolean; + /** Loading state */ + isLoading: boolean; + /** Re-check watch status */ + refresh: () => void; +} + +export function useWatchUsage(): WatchUsageStatus { + const [hasWatchDataToday, setHasWatchDataToday] = useState(false); + const [watchWornRecently, setWatchWornRecently] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [refreshKey, setRefreshKey] = useState(0); + + const refresh = useCallback(() => { + setRefreshKey((k) => k + 1); + }, []); + + useEffect(() => { + let cancelled = false; + + async function checkWatchStatus() { + setIsLoading(true); + + // Stub: on iOS, default to false so the reminder shows. + // When wired to real HealthKit, check for recent heart rate samples. + const hasData = Platform.OS !== 'ios' ? false : false; + + if (!cancelled) { + setHasWatchDataToday(hasData); + setWatchWornRecently(hasData); + setIsLoading(false); + } + } + + checkWatchStatus(); + + return () => { + cancelled = true; + }; + }, [refreshKey]); + + return { hasWatchDataToday, watchWornRecently, isLoading, refresh }; +} diff --git a/homeflow/ios/.gitignore b/homeflow/ios/.gitignore new file mode 100644 index 0000000..8beb344 --- /dev/null +++ b/homeflow/ios/.gitignore @@ -0,0 +1,30 @@ +# 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 +project.xcworkspace +.xcode.env.local + +# Bundle artifacts +*.jsbundle + +# CocoaPods +/Pods/ diff --git a/homeflow/ios/.xcode.env b/homeflow/ios/.xcode.env new file mode 100644 index 0000000..3d5782c --- /dev/null +++ b/homeflow/ios/.xcode.env @@ -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) diff --git a/homeflow/ios/HomeFlow.xcodeproj/project.pbxproj b/homeflow/ios/HomeFlow.xcodeproj/project.pbxproj new file mode 100644 index 0000000..08bbe4d --- /dev/null +++ b/homeflow/ios/HomeFlow.xcodeproj/project.pbxproj @@ -0,0 +1,554 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; + 655D183318E88971DD3F7AE0 /* libPods-HomeFlow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 72BF25086EAF93F6C75C17CA /* libPods-HomeFlow.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + CF1C07D43C780E430189D941 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6182C5A8DD392A829FB19BF4 /* PrivacyInfo.xcprivacy */; }; + D7CE62E5BDE4C6EFC9C558F5 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533C3794235BBF1B031CE10F /* ExpoModulesProvider.swift */; }; + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* HomeFlow.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HomeFlow.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = HomeFlow/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = HomeFlow/Info.plist; sourceTree = ""; }; + 533C3794235BBF1B031CE10F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-HomeFlow/ExpoModulesProvider.swift"; sourceTree = ""; }; + 6182C5A8DD392A829FB19BF4 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = HomeFlow/PrivacyInfo.xcprivacy; sourceTree = ""; }; + 72BF25086EAF93F6C75C17CA /* libPods-HomeFlow.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-HomeFlow.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 755B11EACCD3ACB1D4607CA8 /* Pods-HomeFlow.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HomeFlow.release.xcconfig"; path = "Target Support Files/Pods-HomeFlow/Pods-HomeFlow.release.xcconfig"; sourceTree = ""; }; + 7A117F702A833E3623BB53E9 /* Pods-HomeFlow.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HomeFlow.debug.xcconfig"; path = "Target Support Files/Pods-HomeFlow/Pods-HomeFlow.debug.xcconfig"; sourceTree = ""; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = HomeFlow/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; + F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = HomeFlow/AppDelegate.swift; sourceTree = ""; }; + F11748442D0722820044C1D9 /* HomeFlow-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "HomeFlow-Bridging-Header.h"; path = "HomeFlow/HomeFlow-Bridging-Header.h"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 655D183318E88971DD3F7AE0 /* libPods-HomeFlow.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* HomeFlow */ = { + isa = PBXGroup; + children = ( + F11748412D0307B40044C1D9 /* AppDelegate.swift */, + F11748442D0722820044C1D9 /* HomeFlow-Bridging-Header.h */, + BB2F792B24A3F905000567C9 /* Supporting */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + 6182C5A8DD392A829FB19BF4 /* PrivacyInfo.xcprivacy */, + ); + name = HomeFlow; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 72BF25086EAF93F6C75C17CA /* libPods-HomeFlow.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* HomeFlow */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + D0C0E99CF642259F3C78F17A /* Pods */, + EF3CD28B5F36810F9284ADC6 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* HomeFlow.app */, + ); + name = Products; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = HomeFlow/Supporting; + sourceTree = ""; + }; + BC5E891228A338572873BE9F /* HomeFlow */ = { + isa = PBXGroup; + children = ( + 533C3794235BBF1B031CE10F /* ExpoModulesProvider.swift */, + ); + name = HomeFlow; + sourceTree = ""; + }; + D0C0E99CF642259F3C78F17A /* Pods */ = { + isa = PBXGroup; + children = ( + 7A117F702A833E3623BB53E9 /* Pods-HomeFlow.debug.xcconfig */, + 755B11EACCD3ACB1D4607CA8 /* Pods-HomeFlow.release.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + EF3CD28B5F36810F9284ADC6 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + BC5E891228A338572873BE9F /* HomeFlow */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* HomeFlow */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "HomeFlow" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + EF2B9AF74A1A0DB553FBBAB5 /* [Expo] Configure project */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + 24938B7FA138F660953FB9B6 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = HomeFlow; + productName = HomeFlow; + productReference = 13B07F961A680F5B00A75B9A /* HomeFlow.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 83CBB9F71A601CBA00E9B192 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1130; + TargetAttributes = { + 13B07F861A680F5B00A75B9A = { + LastSwiftMigration = 1250; + DevelopmentTeam = "DR492LE84K"; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HomeFlow" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* HomeFlow */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 13B07F8E1A680F5B00A75B9A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, + 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, + 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, + CF1C07D43C780E430189D941 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; + }; + 08A4A3CD28434E44B6B9DE2E /* [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-HomeFlow-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; + }; + 24938B7FA138F660953FB9B6 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-HomeFlow/Pods-HomeFlow-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HomeFlow/Pods-HomeFlow-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-HomeFlow/Pods-HomeFlow-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle", + ); + name = "[CP] Copy Pods Resources"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-HomeFlow/Pods-HomeFlow-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + EF2B9AF74A1A0DB553FBBAB5 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/HomeFlow/HomeFlow.entitlements", + "$(SRCROOT)/Pods/Target Support Files/Pods-HomeFlow/expo-configure-project.sh", + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Pods/Target Support Files/Pods-HomeFlow/ExpoModulesProvider.swift", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-HomeFlow/expo-configure-project.sh\"\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, + D7CE62E5BDE4C6EFC9C558F5 /* ExpoModulesProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7A117F702A833E3623BB53E9 /* Pods-HomeFlow.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = HomeFlow/HomeFlow.entitlements; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = HomeFlow/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.chehan.homeflow; + PRODUCT_NAME = HomeFlow; + SWIFT_OBJC_BRIDGING_HEADER = "HomeFlow/HomeFlow-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + DEVELOPMENT_TEAM = "DR492LE84K"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 755B11EACCD3ACB1D4607CA8 /* Pods-HomeFlow.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = HomeFlow/HomeFlow.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = HomeFlow/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.chehan.homeflow; + PRODUCT_NAME = HomeFlow; + SWIFT_OBJC_BRIDGING_HEADER = "HomeFlow/HomeFlow-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VERSIONING_SYSTEM = "apple-generic"; + DEVELOPMENT_TEAM = "DR492LE84K"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + }; + 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_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; + 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 = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + 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_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; + 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 = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + /usr/lib/swift, + "$(inherited)", + ); + LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; + MTL_ENABLE_DEBUG_INFO = NO; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "HomeFlow" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "HomeFlow" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/homeflow/ios/HomeFlow.xcodeproj/xcshareddata/xcschemes/HomeFlow.xcscheme b/homeflow/ios/HomeFlow.xcodeproj/xcshareddata/xcschemes/HomeFlow.xcscheme new file mode 100644 index 0000000..f066227 --- /dev/null +++ b/homeflow/ios/HomeFlow.xcodeproj/xcshareddata/xcschemes/HomeFlow.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/homeflow/ios/HomeFlow.xcworkspace/contents.xcworkspacedata b/homeflow/ios/HomeFlow.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..0158ddf --- /dev/null +++ b/homeflow/ios/HomeFlow.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/homeflow/ios/HomeFlow/AppDelegate.swift b/homeflow/ios/HomeFlow/AppDelegate.swift new file mode 100644 index 0000000..a7887e1 --- /dev/null +++ b/homeflow/ios/HomeFlow/AppDelegate.swift @@ -0,0 +1,70 @@ +import Expo +import React +import ReactAppDependencyProvider + +@UIApplicationMain +public class AppDelegate: ExpoAppDelegate { + var window: UIWindow? + + var reactNativeDelegate: ExpoReactNativeFactoryDelegate? + var reactNativeFactory: RCTReactNativeFactory? + + public override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let delegate = ReactNativeDelegate() + let factory = ExpoReactNativeFactory(delegate: delegate) + delegate.dependencyProvider = RCTAppDependencyProvider() + + reactNativeDelegate = delegate + reactNativeFactory = factory + bindReactNativeFactory(factory) + +#if os(iOS) || os(tvOS) + window = UIWindow(frame: UIScreen.main.bounds) + factory.startReactNative( + withModuleName: "main", + in: window, + launchOptions: launchOptions) +#endif + + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // Linking API + public override func application( + _ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + return super.application(app, open: url, options: options) || RCTLinkingManager.application(app, open: url, options: options) + } + + // Universal Links + public override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + let result = RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) || result + } +} + +class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { + // Extension point for config-plugins + + override func sourceURL(for bridge: RCTBridge) -> URL? { + // needed to return the correct URL for expo-dev-client. + bridge.bundleURL ?? bundleURL() + } + + override func bundleURL() -> URL? { +#if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: ".expo/.virtual-metro-entry") +#else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") +#endif + } +} diff --git a/homeflow/ios/HomeFlow/HomeFlow-Bridging-Header.h b/homeflow/ios/HomeFlow/HomeFlow-Bridging-Header.h new file mode 100644 index 0000000..8361941 --- /dev/null +++ b/homeflow/ios/HomeFlow/HomeFlow-Bridging-Header.h @@ -0,0 +1,3 @@ +// +// Use this file to import your target's public headers that you would like to expose to Swift. +// diff --git a/homeflow/ios/HomeFlow/HomeFlow.entitlements b/homeflow/ios/HomeFlow/HomeFlow.entitlements new file mode 100644 index 0000000..dca2b85 --- /dev/null +++ b/homeflow/ios/HomeFlow/HomeFlow.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.healthkit + + com.apple.developer.healthkit.access + + health-records + + com.apple.developer.healthkit.background-delivery + + + \ No newline at end of file diff --git a/homeflow/ios/HomeFlow/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/homeflow/ios/HomeFlow/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png new file mode 100644 index 0000000..3d6c9ef Binary files /dev/null and b/homeflow/ios/HomeFlow/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/homeflow/ios/HomeFlow/Images.xcassets/AppIcon.appiconset/Contents.json b/homeflow/ios/HomeFlow/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90d8d4c --- /dev/null +++ b/homeflow/ios/HomeFlow/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "App-Icon-1024x1024@1x.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "version": 1, + "author": "expo" + } +} \ No newline at end of file diff --git a/homeflow/ios/HomeFlow/Images.xcassets/Contents.json b/homeflow/ios/HomeFlow/Images.xcassets/Contents.json new file mode 100644 index 0000000..ed285c2 --- /dev/null +++ b/homeflow/ios/HomeFlow/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "expo" + } +} diff --git a/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenBackground.colorset/Contents.json b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenBackground.colorset/Contents.json new file mode 100644 index 0000000..0d8c753 --- /dev/null +++ b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "components": { + "alpha": "1.000", + "blue": "1.00000000000000", + "green": "1.00000000000000", + "red": "1.00000000000000" + }, + "color-space": "srgb" + }, + "idiom": "universal" + }, + { + "color": { + "components": { + "alpha": "1.000", + "blue": "0.00000000000000", + "green": "0.00000000000000", + "red": "0.00000000000000" + }, + "color-space": "srgb" + }, + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "version": 1, + "author": "expo" + } +} \ No newline at end of file diff --git a/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/Contents.json b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/Contents.json new file mode 100644 index 0000000..f65c008 --- /dev/null +++ b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "image.png", + "scale": "1x" + }, + { + "idiom": "universal", + "filename": "image@2x.png", + "scale": "2x" + }, + { + "idiom": "universal", + "filename": "image@3x.png", + "scale": "3x" + } + ], + "info": { + "version": 1, + "author": "expo" + } +} \ No newline at end of file diff --git a/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image.png b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image.png new file mode 100644 index 0000000..635530a Binary files /dev/null and b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image.png differ diff --git a/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image@2x.png b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image@2x.png new file mode 100644 index 0000000..d4e41ea Binary files /dev/null and b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image@2x.png differ diff --git a/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image@3x.png b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image@3x.png new file mode 100644 index 0000000..67ee127 Binary files /dev/null and b/homeflow/ios/HomeFlow/Images.xcassets/SplashScreenLogo.imageset/image@3x.png differ diff --git a/homeflow/ios/HomeFlow/Info.plist b/homeflow/ios/HomeFlow/Info.plist new file mode 100644 index 0000000..e813a92 --- /dev/null +++ b/homeflow/ios/HomeFlow/Info.plist @@ -0,0 +1,87 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + HomeFlow + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0.0 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + my-app + com.chehan.homeflow + + + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSHealthClinicalHealthRecordsShareUsageDescription + HomeFlow would like to access your clinical health records to import medications, lab results, and conditions — reducing manual data entry. + NSHealthShareUsageDescription + This app needs access to your health data to display your health metrics and track your progress. + NSHealthUpdateUsageDescription + This app needs permission to save health data to track your activities. + NSUserActivityTypes + + $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route + + RCTNewArchEnabled + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIUserInterfaceStyle + Automatic + UIViewControllerBasedStatusBarAppearance + + + \ No newline at end of file diff --git a/homeflow/ios/HomeFlow/PrivacyInfo.xcprivacy b/homeflow/ios/HomeFlow/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5bb83c5 --- /dev/null +++ b/homeflow/ios/HomeFlow/PrivacyInfo.xcprivacy @@ -0,0 +1,48 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + 0A2A.1 + 3B52.1 + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + 85F4.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/homeflow/ios/HomeFlow/SplashScreen.storyboard b/homeflow/ios/HomeFlow/SplashScreen.storyboard new file mode 100644 index 0000000..240e893 --- /dev/null +++ b/homeflow/ios/HomeFlow/SplashScreen.storyboard @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/homeflow/ios/HomeFlow/Supporting/Expo.plist b/homeflow/ios/HomeFlow/Supporting/Expo.plist new file mode 100644 index 0000000..750be02 --- /dev/null +++ b/homeflow/ios/HomeFlow/Supporting/Expo.plist @@ -0,0 +1,12 @@ + + + + + EXUpdatesCheckOnLaunch + ALWAYS + EXUpdatesEnabled + + EXUpdatesLaunchWaitMs + 0 + + \ No newline at end of file diff --git a/homeflow/ios/Podfile b/homeflow/ios/Podfile new file mode 100644 index 0000000..036ace7 --- /dev/null +++ b/homeflow/ios/Podfile @@ -0,0 +1,72 @@ +require File.join(File.dirname(`node --print "require.resolve('expo/package.json')"`), "scripts/autolinking") +require File.join(File.dirname(`node --print "require.resolve('react-native/package.json')"`), "scripts/react_native_pods") + +require 'json' +podfile_properties = JSON.parse(File.read(File.join(__dir__, 'Podfile.properties.json'))) rescue {} + +def ccache_enabled?(podfile_properties) + # Environment variable takes precedence + return ENV['USE_CCACHE'] == '1' if ENV['USE_CCACHE'] + + # Fall back to Podfile properties + podfile_properties['apple.ccacheEnabled'] == 'true' +end + +ENV['RCT_NEW_ARCH_ENABLED'] ||= '0' if podfile_properties['newArchEnabled'] == 'false' +ENV['EX_DEV_CLIENT_NETWORK_INSPECTOR'] ||= podfile_properties['EX_DEV_CLIENT_NETWORK_INSPECTOR'] +ENV['RCT_USE_RN_DEP'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false' +ENV['RCT_USE_PREBUILT_RNCORE'] ||= '1' if podfile_properties['ios.buildReactNativeFromSource'] != 'true' && podfile_properties['newArchEnabled'] != 'false' +platform :ios, podfile_properties['ios.deploymentTarget'] || '15.1' + +prepare_react_native_project! + +target 'HomeFlow' do + use_expo_modules! + + if ENV['EXPO_USE_COMMUNITY_AUTOLINKING'] == '1' + config_command = ['node', '-e', "process.argv=['', '', 'config'];require('@react-native-community/cli').run()"]; + else + config_command = [ + 'node', + '--no-warnings', + '--eval', + 'require(\'expo/bin/autolinking\')', + 'expo-modules-autolinking', + 'react-native-config', + '--json', + '--platform', + 'ios' + ] + end + + config = use_native_modules!(config_command) + + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] + use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS'] + + use_react_native!( + :path => config[:reactNativePath], + :hermes_enabled => podfile_properties['expo.jsEngine'] == nil || podfile_properties['expo.jsEngine'] == 'hermes', + # An absolute path to your application root. + :app_path => "#{Pod::Config.instance.installation_root}/..", + :privacy_file_aggregation_enabled => podfile_properties['apple.privacyManifestAggregationEnabled'] != 'false', + ) + + post_install do |installer| + react_native_post_install( + installer, + config[:reactNativePath], + :mac_catalyst_enabled => false, + :ccache_enabled => ccache_enabled?(podfile_properties), + ) + + # Force minimum deployment target for all pods + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + if config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'].to_f < 16.0 + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.0' + end + end + end + end +end diff --git a/homeflow/ios/Podfile.lock b/homeflow/ios/Podfile.lock new file mode 100644 index 0000000..4a15153 --- /dev/null +++ b/homeflow/ios/Podfile.lock @@ -0,0 +1,2490 @@ +PODS: + - ClinicalRecordsModule (0.1.0): + - ExpoModulesCore + - EXConstants (18.0.13): + - ExpoModulesCore + - Expo (54.0.32): + - ExpoModulesCore + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTAppDelegate + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactAppDependencyProvider + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - ExpoAsset (12.0.12): + - ExpoModulesCore + - ExpoFileSystem (19.0.21): + - ExpoModulesCore + - ExpoFont (14.0.11): + - ExpoModulesCore + - ExpoHaptics (15.0.8): + - ExpoModulesCore + - ExpoHead (6.0.22): + - ExpoModulesCore + - RNScreens + - ExpoImage (3.0.11): + - ExpoModulesCore + - libavif/libdav1d + - SDWebImage (~> 5.21.0) + - SDWebImageAVIFCoder (~> 0.11.0) + - SDWebImageSVGCoder (~> 1.7.0) + - SDWebImageWebPCoder (~> 0.14.6) + - ExpoKeepAwake (15.0.8): + - ExpoModulesCore + - ExpoLinking (8.0.11): + - ExpoModulesCore + - ExpoModulesCore (3.0.29): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsinspector + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - ExpoSplashScreen (31.0.13): + - ExpoModulesCore + - ExpoSymbols (1.0.8): + - ExpoModulesCore + - ExpoSystemUI (6.0.9): + - ExpoModulesCore + - ExpoWebBrowser (15.0.10): + - ExpoModulesCore + - FBLazyVector (0.81.5) + - hermes-engine (0.81.5): + - hermes-engine/Pre-built (= 0.81.5) + - hermes-engine/Pre-built (0.81.5) + - libavif/core (1.0.0) + - libavif/libdav1d (1.0.0): + - libavif/core + - libdav1d (>= 0.6.0) + - libdav1d (1.2.0) + - libwebp (1.5.0): + - libwebp/demux (= 1.5.0) + - libwebp/mux (= 1.5.0) + - libwebp/sharpyuv (= 1.5.0) + - libwebp/webp (= 1.5.0) + - libwebp/demux (1.5.0): + - libwebp/webp + - libwebp/mux (1.5.0): + - libwebp/demux + - libwebp/sharpyuv (1.5.0) + - libwebp/webp (1.5.0): + - libwebp/sharpyuv + - NitroModules (0.33.7): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RCTDeprecation (0.81.5) + - RCTRequired (0.81.5) + - RCTTypeSafety (0.81.5): + - FBLazyVector (= 0.81.5) + - RCTRequired (= 0.81.5) + - React-Core (= 0.81.5) + - React (0.81.5): + - React-Core (= 0.81.5) + - React-Core/DevSupport (= 0.81.5) + - React-Core/RCTWebSocket (= 0.81.5) + - React-RCTActionSheet (= 0.81.5) + - React-RCTAnimation (= 0.81.5) + - React-RCTBlob (= 0.81.5) + - React-RCTImage (= 0.81.5) + - React-RCTLinking (= 0.81.5) + - React-RCTNetwork (= 0.81.5) + - React-RCTSettings (= 0.81.5) + - React-RCTText (= 0.81.5) + - React-RCTVibration (= 0.81.5) + - React-callinvoker (0.81.5) + - React-Core (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.81.5) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core-prebuilt (0.81.5): + - ReactNativeDependencies + - React-Core/CoreModulesHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/Default (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/DevSupport (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.81.5) + - React-Core/RCTWebSocket (= 0.81.5) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTActionSheetHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTAnimationHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTBlobHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTImageHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTLinkingHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTNetworkHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTSettingsHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTTextHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTVibrationHeaders (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-Core/RCTWebSocket (0.81.5): + - hermes-engine + - RCTDeprecation + - React-Core-prebuilt + - React-Core/Default (= 0.81.5) + - React-cxxreact + - React-featureflags + - React-hermes + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsinspectorcdp + - React-jsitooling + - React-perflogger + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-CoreModules (0.81.5): + - RCTTypeSafety (= 0.81.5) + - React-Core-prebuilt + - React-Core/CoreModulesHeaders (= 0.81.5) + - React-jsi (= 0.81.5) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-NativeModulesApple + - React-RCTBlob + - React-RCTFBReactNativeSpec + - React-RCTImage (= 0.81.5) + - React-runtimeexecutor + - ReactCommon + - ReactNativeDependencies + - React-cxxreact (0.81.5): + - hermes-engine + - React-callinvoker (= 0.81.5) + - React-Core-prebuilt + - React-debug (= 0.81.5) + - React-jsi (= 0.81.5) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-logger (= 0.81.5) + - React-perflogger (= 0.81.5) + - React-runtimeexecutor + - React-timing (= 0.81.5) + - ReactNativeDependencies + - React-debug (0.81.5) + - React-defaultsnativemodule (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-domnativemodule + - React-featureflagsnativemodule + - React-idlecallbacksnativemodule + - React-jsi + - React-jsiexecutor + - React-microtasksnativemodule + - React-RCTFBReactNativeSpec + - ReactNativeDependencies + - React-domnativemodule (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-Fabric + - React-Fabric/bridging + - React-FabricComponents + - React-graphics + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/animations (= 0.81.5) + - React-Fabric/attributedstring (= 0.81.5) + - React-Fabric/bridging (= 0.81.5) + - React-Fabric/componentregistry (= 0.81.5) + - React-Fabric/componentregistrynative (= 0.81.5) + - React-Fabric/components (= 0.81.5) + - React-Fabric/consistency (= 0.81.5) + - React-Fabric/core (= 0.81.5) + - React-Fabric/dom (= 0.81.5) + - React-Fabric/imagemanager (= 0.81.5) + - React-Fabric/leakchecker (= 0.81.5) + - React-Fabric/mounting (= 0.81.5) + - React-Fabric/observers (= 0.81.5) + - React-Fabric/scheduler (= 0.81.5) + - React-Fabric/telemetry (= 0.81.5) + - React-Fabric/templateprocessor (= 0.81.5) + - React-Fabric/uimanager (= 0.81.5) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/animations (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/attributedstring (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/bridging (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistry (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/componentregistrynative (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/components/legacyviewmanagerinterop (= 0.81.5) + - React-Fabric/components/root (= 0.81.5) + - React-Fabric/components/scrollview (= 0.81.5) + - React-Fabric/components/view (= 0.81.5) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/legacyviewmanagerinterop (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/root (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/scrollview (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/components/view (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-Fabric/consistency (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/core (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/dom (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/imagemanager (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/leakchecker (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/mounting (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/observers/events (= 0.81.5) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/observers/events (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/scheduler (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/observers/events + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-performancetimeline + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/telemetry (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/templateprocessor (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric/uimanager/consistency (= 0.81.5) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-Fabric/uimanager/consistency (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-FabricComponents (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components (= 0.81.5) + - React-FabricComponents/textlayoutmanager (= 0.81.5) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-FabricComponents/components/inputaccessory (= 0.81.5) + - React-FabricComponents/components/iostextinput (= 0.81.5) + - React-FabricComponents/components/modal (= 0.81.5) + - React-FabricComponents/components/rncore (= 0.81.5) + - React-FabricComponents/components/safeareaview (= 0.81.5) + - React-FabricComponents/components/scrollview (= 0.81.5) + - React-FabricComponents/components/switch (= 0.81.5) + - React-FabricComponents/components/text (= 0.81.5) + - React-FabricComponents/components/textinput (= 0.81.5) + - React-FabricComponents/components/unimplementedview (= 0.81.5) + - React-FabricComponents/components/virtualview (= 0.81.5) + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/inputaccessory (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/iostextinput (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/modal (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/rncore (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/safeareaview (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/scrollview (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/switch (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/text (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/textinput (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/unimplementedview (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/components/virtualview (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricComponents/textlayoutmanager (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-logger + - React-RCTFBReactNativeSpec + - React-rendererdebug + - React-runtimescheduler + - React-utils + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-FabricImage (0.81.5): + - hermes-engine + - RCTRequired (= 0.81.5) + - RCTTypeSafety (= 0.81.5) + - React-Core-prebuilt + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsiexecutor (= 0.81.5) + - React-logger + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-featureflags (0.81.5): + - React-Core-prebuilt + - ReactNativeDependencies + - React-featureflagsnativemodule (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-graphics (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-utils + - ReactNativeDependencies + - React-hermes (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.81.5) + - React-jsi + - React-jsiexecutor (= 0.81.5) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-perflogger (= 0.81.5) + - React-runtimeexecutor + - ReactNativeDependencies + - React-idlecallbacksnativemodule (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - React-runtimeexecutor + - React-runtimescheduler + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-ImageManager (0.81.5): + - React-Core-prebuilt + - React-Core/Default + - React-debug + - React-Fabric + - React-graphics + - React-rendererdebug + - React-utils + - ReactNativeDependencies + - React-jserrorhandler (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - ReactCommon/turbomodule/bridging + - ReactNativeDependencies + - React-jsi (0.81.5): + - hermes-engine + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsiexecutor (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact (= 0.81.5) + - React-jsi (= 0.81.5) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-perflogger (= 0.81.5) + - React-runtimeexecutor + - ReactNativeDependencies + - React-jsinspector (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-oscompat + - React-perflogger (= 0.81.5) + - React-runtimeexecutor + - ReactNativeDependencies + - React-jsinspectorcdp (0.81.5): + - React-Core-prebuilt + - ReactNativeDependencies + - React-jsinspectornetwork (0.81.5): + - React-Core-prebuilt + - React-featureflags + - React-jsinspectorcdp + - React-performancetimeline + - React-timing + - ReactNativeDependencies + - React-jsinspectortracing (0.81.5): + - React-Core-prebuilt + - React-oscompat + - React-timing + - ReactNativeDependencies + - React-jsitooling (0.81.5): + - React-Core-prebuilt + - React-cxxreact (= 0.81.5) + - React-jsi (= 0.81.5) + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-runtimeexecutor + - ReactNativeDependencies + - React-jsitracing (0.81.5): + - React-jsi + - React-logger (0.81.5): + - React-Core-prebuilt + - ReactNativeDependencies + - React-Mapbuffer (0.81.5): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-microtasksnativemodule (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-jsi + - React-jsiexecutor + - React-RCTFBReactNativeSpec + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - react-native-safe-area-context (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common (= 5.6.2) + - react-native-safe-area-context/fabric (= 5.6.2) + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/common (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-safe-area-context/fabric (5.6.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - react-native-safe-area-context/common + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - React-NativeModulesApple (0.81.5): + - hermes-engine + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-cxxreact + - React-featureflags + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-runtimeexecutor + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - React-oscompat (0.81.5) + - React-perflogger (0.81.5): + - React-Core-prebuilt + - ReactNativeDependencies + - React-performancetimeline (0.81.5): + - React-Core-prebuilt + - React-featureflags + - React-jsinspectortracing + - React-perflogger + - React-timing + - ReactNativeDependencies + - React-RCTActionSheet (0.81.5): + - React-Core/RCTActionSheetHeaders (= 0.81.5) + - React-RCTAnimation (0.81.5): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTAnimationHeaders + - React-featureflags + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTAppDelegate (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-CoreModules + - React-debug + - React-defaultsnativemodule + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-jsitooling + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTNetwork + - React-RCTRuntime + - React-rendererdebug + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactCommon + - ReactNativeDependencies + - React-RCTBlob (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-Core/RCTBlobHeaders + - React-Core/RCTWebSocket + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTFabric (0.81.5): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricComponents + - React-FabricImage + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-jsinspectortracing + - React-performancetimeline + - React-RCTAnimation + - React-RCTFBReactNativeSpec + - React-RCTImage + - React-RCTText + - React-rendererconsistency + - React-renderercss + - React-rendererdebug + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - Yoga + - React-RCTFBReactNativeSpec (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec/components (= 0.81.5) + - ReactCommon + - ReactNativeDependencies + - React-RCTFBReactNativeSpec/components (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-jsi + - React-NativeModulesApple + - React-rendererdebug + - React-utils + - ReactCommon + - ReactNativeDependencies + - Yoga + - React-RCTImage (0.81.5): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTImageHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - React-RCTNetwork + - ReactCommon + - ReactNativeDependencies + - React-RCTLinking (0.81.5): + - React-Core/RCTLinkingHeaders (= 0.81.5) + - React-jsi (= 0.81.5) + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactCommon/turbomodule/core (= 0.81.5) + - React-RCTNetwork (0.81.5): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTNetworkHeaders + - React-featureflags + - React-jsi + - React-jsinspectorcdp + - React-jsinspectornetwork + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTRuntime (0.81.5): + - hermes-engine + - React-Core + - React-Core-prebuilt + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-RuntimeApple + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - ReactNativeDependencies + - React-RCTSettings (0.81.5): + - RCTTypeSafety + - React-Core-prebuilt + - React-Core/RCTSettingsHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-RCTText (0.81.5): + - React-Core/RCTTextHeaders (= 0.81.5) + - Yoga + - React-RCTVibration (0.81.5): + - React-Core-prebuilt + - React-Core/RCTVibrationHeaders + - React-jsi + - React-NativeModulesApple + - React-RCTFBReactNativeSpec + - ReactCommon + - ReactNativeDependencies + - React-rendererconsistency (0.81.5) + - React-renderercss (0.81.5): + - React-debug + - React-utils + - React-rendererdebug (0.81.5): + - React-Core-prebuilt + - React-debug + - ReactNativeDependencies + - React-RuntimeApple (0.81.5): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-Core/Default + - React-CoreModules + - React-cxxreact + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-Mapbuffer + - React-NativeModulesApple + - React-RCTFabric + - React-RCTFBReactNativeSpec + - React-RuntimeCore + - React-runtimeexecutor + - React-RuntimeHermes + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-RuntimeCore (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-cxxreact + - React-Fabric + - React-featureflags + - React-jserrorhandler + - React-jsi + - React-jsiexecutor + - React-jsinspector + - React-jsitooling + - React-performancetimeline + - React-runtimeexecutor + - React-runtimescheduler + - React-utils + - ReactNativeDependencies + - React-runtimeexecutor (0.81.5): + - React-Core-prebuilt + - React-debug + - React-featureflags + - React-jsi (= 0.81.5) + - React-utils + - ReactNativeDependencies + - React-RuntimeHermes (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-featureflags + - React-hermes + - React-jsi + - React-jsinspector + - React-jsinspectorcdp + - React-jsinspectortracing + - React-jsitooling + - React-jsitracing + - React-RuntimeCore + - React-runtimeexecutor + - React-utils + - ReactNativeDependencies + - React-runtimescheduler (0.81.5): + - hermes-engine + - React-callinvoker + - React-Core-prebuilt + - React-cxxreact + - React-debug + - React-featureflags + - React-jsi + - React-jsinspectortracing + - React-performancetimeline + - React-rendererconsistency + - React-rendererdebug + - React-runtimeexecutor + - React-timing + - React-utils + - ReactNativeDependencies + - React-timing (0.81.5): + - React-debug + - React-utils (0.81.5): + - hermes-engine + - React-Core-prebuilt + - React-debug + - React-jsi (= 0.81.5) + - ReactNativeDependencies + - ReactAppDependencyProvider (0.81.5): + - ReactCodegen + - ReactCodegen (0.81.5): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-FabricImage + - React-featureflags + - React-graphics + - React-jsi + - React-jsiexecutor + - React-NativeModulesApple + - React-RCTAppDelegate + - React-rendererdebug + - React-utils + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactCommon (0.81.5): + - React-Core-prebuilt + - ReactCommon/turbomodule (= 0.81.5) + - ReactNativeDependencies + - ReactCommon/turbomodule (0.81.5): + - hermes-engine + - React-callinvoker (= 0.81.5) + - React-Core-prebuilt + - React-cxxreact (= 0.81.5) + - React-jsi (= 0.81.5) + - React-logger (= 0.81.5) + - React-perflogger (= 0.81.5) + - ReactCommon/turbomodule/bridging (= 0.81.5) + - ReactCommon/turbomodule/core (= 0.81.5) + - ReactNativeDependencies + - ReactCommon/turbomodule/bridging (0.81.5): + - hermes-engine + - React-callinvoker (= 0.81.5) + - React-Core-prebuilt + - React-cxxreact (= 0.81.5) + - React-jsi (= 0.81.5) + - React-logger (= 0.81.5) + - React-perflogger (= 0.81.5) + - ReactNativeDependencies + - ReactCommon/turbomodule/core (0.81.5): + - hermes-engine + - React-callinvoker (= 0.81.5) + - React-Core-prebuilt + - React-cxxreact (= 0.81.5) + - React-debug (= 0.81.5) + - React-featureflags (= 0.81.5) + - React-jsi (= 0.81.5) + - React-logger (= 0.81.5) + - React-perflogger (= 0.81.5) + - React-utils (= 0.81.5) + - ReactNativeDependencies + - ReactNativeDependencies (0.81.5) + - ReactNativeHealthkit (13.1.1): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNCAsyncStorage (2.2.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNGestureHandler (2.28.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNReanimated (4.1.6): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNReanimated/reanimated (= 4.1.6) + - RNWorklets + - Yoga + - RNReanimated/reanimated (4.1.6): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNReanimated/reanimated/apple (= 4.1.6) + - RNWorklets + - Yoga + - RNReanimated/reanimated/apple (4.1.6): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - RNScreens (4.16.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNScreens/common (= 4.16.0) + - Yoga + - RNScreens/common (4.16.0): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-RCTImage + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - RNWorklets (0.5.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/worklets (= 0.5.1) + - Yoga + - RNWorklets/worklets (0.5.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets/worklets/apple (= 0.5.1) + - Yoga + - RNWorklets/worklets/apple (0.5.1): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - SDWebImage (5.21.6): + - SDWebImage/Core (= 5.21.6) + - SDWebImage/Core (5.21.6) + - SDWebImageAVIFCoder (0.11.1): + - libavif/core (>= 0.11.0) + - SDWebImage (~> 5.10) + - SDWebImageSVGCoder (1.7.0): + - SDWebImage/Core (~> 5.6) + - SDWebImageWebPCoder (0.14.6): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) + - Yoga (0.0.0) + +DEPENDENCIES: + - ClinicalRecordsModule (from `../modules/expo-clinical-records/ios`) + - EXConstants (from `../node_modules/expo-constants/ios`) + - Expo (from `../node_modules/expo`) + - ExpoAsset (from `../node_modules/expo-asset/ios`) + - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) + - ExpoFont (from `../node_modules/expo-font/ios`) + - ExpoHaptics (from `../node_modules/expo-haptics/ios`) + - ExpoHead (from `../node_modules/expo-router/ios`) + - ExpoImage (from `../node_modules/expo-image/ios`) + - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) + - ExpoLinking (from `../node_modules/expo-linking/ios`) + - ExpoModulesCore (from `../node_modules/expo-modules-core`) + - ExpoSplashScreen (from `../node_modules/expo-splash-screen/ios`) + - ExpoSymbols (from `../node_modules/expo-symbols/ios`) + - ExpoSystemUI (from `../node_modules/expo-system-ui/ios`) + - ExpoWebBrowser (from `../node_modules/expo-web-browser/ios`) + - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) + - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - NitroModules (from `../node_modules/react-native-nitro-modules`) + - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../node_modules/react-native/Libraries/Required`) + - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../node_modules/react-native/`) + - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../node_modules/react-native/`) + - React-Core-prebuilt (from `../node_modules/react-native/React-Core-prebuilt.podspec`) + - React-Core/RCTWebSocket (from `../node_modules/react-native/`) + - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) + - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) + - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) + - ReactAppDependencyProvider (from `build/generated/ios`) + - ReactCodegen (from `build/generated/ios`) + - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - ReactNativeDependencies (from `../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) + - "ReactNativeHealthkit (from `../node_modules/@kingstinct/react-native-healthkit`)" + - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - RNGestureHandler (from `../node_modules/react-native-gesture-handler`) + - RNReanimated (from `../node_modules/react-native-reanimated`) + - RNScreens (from `../node_modules/react-native-screens`) + - RNWorklets (from `../node_modules/react-native-worklets`) + - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) + +SPEC REPOS: + trunk: + - libavif + - libdav1d + - libwebp + - SDWebImage + - SDWebImageAVIFCoder + - SDWebImageSVGCoder + - SDWebImageWebPCoder + +EXTERNAL SOURCES: + ClinicalRecordsModule: + :path: "../modules/expo-clinical-records/ios" + EXConstants: + :path: "../node_modules/expo-constants/ios" + Expo: + :path: "../node_modules/expo" + ExpoAsset: + :path: "../node_modules/expo-asset/ios" + ExpoFileSystem: + :path: "../node_modules/expo-file-system/ios" + ExpoFont: + :path: "../node_modules/expo-font/ios" + ExpoHaptics: + :path: "../node_modules/expo-haptics/ios" + ExpoHead: + :path: "../node_modules/expo-router/ios" + ExpoImage: + :path: "../node_modules/expo-image/ios" + ExpoKeepAwake: + :path: "../node_modules/expo-keep-awake/ios" + ExpoLinking: + :path: "../node_modules/expo-linking/ios" + ExpoModulesCore: + :path: "../node_modules/expo-modules-core" + ExpoSplashScreen: + :path: "../node_modules/expo-splash-screen/ios" + ExpoSymbols: + :path: "../node_modules/expo-symbols/ios" + ExpoSystemUI: + :path: "../node_modules/expo-system-ui/ios" + ExpoWebBrowser: + :path: "../node_modules/expo-web-browser/ios" + FBLazyVector: + :path: "../node_modules/react-native/Libraries/FBLazyVector" + hermes-engine: + :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :tag: hermes-2025-07-07-RNv0.81.0-e0fc67142ec0763c6b6153ca2bf96df815539782 + NitroModules: + :path: "../node_modules/react-native-nitro-modules" + RCTDeprecation: + :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + RCTRequired: + :path: "../node_modules/react-native/Libraries/Required" + RCTTypeSafety: + :path: "../node_modules/react-native/Libraries/TypeSafety" + React: + :path: "../node_modules/react-native/" + React-callinvoker: + :path: "../node_modules/react-native/ReactCommon/callinvoker" + React-Core: + :path: "../node_modules/react-native/" + React-Core-prebuilt: + :podspec: "../node_modules/react-native/React-Core-prebuilt.podspec" + React-CoreModules: + :path: "../node_modules/react-native/React/CoreModules" + React-cxxreact: + :path: "../node_modules/react-native/ReactCommon/cxxreact" + React-debug: + :path: "../node_modules/react-native/ReactCommon/react/debug" + React-defaultsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + React-domnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" + React-Fabric: + :path: "../node_modules/react-native/ReactCommon" + React-FabricComponents: + :path: "../node_modules/react-native/ReactCommon" + React-FabricImage: + :path: "../node_modules/react-native/ReactCommon" + React-featureflags: + :path: "../node_modules/react-native/ReactCommon/react/featureflags" + React-featureflagsnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + React-graphics: + :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" + React-hermes: + :path: "../node_modules/react-native/ReactCommon/hermes" + React-idlecallbacksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + React-ImageManager: + :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + React-jserrorhandler: + :path: "../node_modules/react-native/ReactCommon/jserrorhandler" + React-jsi: + :path: "../node_modules/react-native/ReactCommon/jsi" + React-jsiexecutor: + :path: "../node_modules/react-native/ReactCommon/jsiexecutor" + React-jsinspector: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" + React-jsinspectorcdp: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + React-jsinspectornetwork: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" + React-jsinspectortracing: + :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + React-jsitooling: + :path: "../node_modules/react-native/ReactCommon/jsitooling" + React-jsitracing: + :path: "../node_modules/react-native/ReactCommon/hermes/executor/" + React-logger: + :path: "../node_modules/react-native/ReactCommon/logger" + React-Mapbuffer: + :path: "../node_modules/react-native/ReactCommon" + React-microtasksnativemodule: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + react-native-safe-area-context: + :path: "../node_modules/react-native-safe-area-context" + React-NativeModulesApple: + :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + React-oscompat: + :path: "../node_modules/react-native/ReactCommon/oscompat" + React-perflogger: + :path: "../node_modules/react-native/ReactCommon/reactperflogger" + React-performancetimeline: + :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" + React-RCTActionSheet: + :path: "../node_modules/react-native/Libraries/ActionSheetIOS" + React-RCTAnimation: + :path: "../node_modules/react-native/Libraries/NativeAnimation" + React-RCTAppDelegate: + :path: "../node_modules/react-native/Libraries/AppDelegate" + React-RCTBlob: + :path: "../node_modules/react-native/Libraries/Blob" + React-RCTFabric: + :path: "../node_modules/react-native/React" + React-RCTFBReactNativeSpec: + :path: "../node_modules/react-native/React" + React-RCTImage: + :path: "../node_modules/react-native/Libraries/Image" + React-RCTLinking: + :path: "../node_modules/react-native/Libraries/LinkingIOS" + React-RCTNetwork: + :path: "../node_modules/react-native/Libraries/Network" + React-RCTRuntime: + :path: "../node_modules/react-native/React/Runtime" + React-RCTSettings: + :path: "../node_modules/react-native/Libraries/Settings" + React-RCTText: + :path: "../node_modules/react-native/Libraries/Text" + React-RCTVibration: + :path: "../node_modules/react-native/Libraries/Vibration" + React-rendererconsistency: + :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" + React-renderercss: + :path: "../node_modules/react-native/ReactCommon/react/renderer/css" + React-rendererdebug: + :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" + React-RuntimeApple: + :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + React-RuntimeCore: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimeexecutor: + :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" + React-RuntimeHermes: + :path: "../node_modules/react-native/ReactCommon/react/runtime" + React-runtimescheduler: + :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + React-timing: + :path: "../node_modules/react-native/ReactCommon/react/timing" + React-utils: + :path: "../node_modules/react-native/ReactCommon/react/utils" + ReactAppDependencyProvider: + :path: build/generated/ios + ReactCodegen: + :path: build/generated/ios + ReactCommon: + :path: "../node_modules/react-native/ReactCommon" + ReactNativeDependencies: + :podspec: "../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec" + ReactNativeHealthkit: + :path: "../node_modules/@kingstinct/react-native-healthkit" + RNCAsyncStorage: + :path: "../node_modules/@react-native-async-storage/async-storage" + RNGestureHandler: + :path: "../node_modules/react-native-gesture-handler" + RNReanimated: + :path: "../node_modules/react-native-reanimated" + RNScreens: + :path: "../node_modules/react-native-screens" + RNWorklets: + :path: "../node_modules/react-native-worklets" + Yoga: + :path: "../node_modules/react-native/ReactCommon/yoga" + +SPEC CHECKSUMS: + ClinicalRecordsModule: ca5a9ab487199aa4fe864e4fa00f4cc497f9b6ff + EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 + Expo: 4e503a041c59c4e34c8be262a135848ad5cd3710 + ExpoAsset: f867e55ceb428aab99e1e8c082b5aee7c159ea18 + ExpoFileSystem: 858a44267a3e6e9057e0888ad7c7cfbf55d52063 + ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47 + ExpoHaptics: d3a6375d8dcc3a1083d003bc2298ff654fafb536 + ExpoHead: 4425246bc93411f0fe7f6945f95f698e91db8780 + ExpoImage: 686f972bff29525733aa13357f6691dc90aa03d8 + ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 + ExpoLinking: 8f0aaf69aa56f832913030503b6263dc6f647f37 + ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583 + ExpoSplashScreen: bc3cffefca2716e5f22350ca109badd7e50ec14d + ExpoSymbols: 349ee2b4d7d5ff3ea8436467914f8a67635aa354 + ExpoSystemUI: 2ad325f361a2fcd96a464e8574e19935c461c9cc + ExpoWebBrowser: 17b064c621789e41d4816c95c93f429b84971f52 + FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 + hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 + libavif: 5f8e715bea24debec477006f21ef9e95432e254d + libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f + libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8 + NitroModules: a5ca899bb0c1326c937f11de6b050a467c5a12de + RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990 + RCTRequired: 8f3cfc90cc25cf6e420ddb3e7caaaabc57df6043 + RCTTypeSafety: 16a4144ca3f959583ab019b57d5633df10b5e97c + React: 914f8695f9bf38e6418228c2ffb70021e559f92f + React-callinvoker: 1c0808402aee0c6d4a0d8e7220ce6547af9fba71 + React-Core: c61410ef0ca6055e204a963992e363227e0fd1c5 + React-Core-prebuilt: 02f0ad625ddd47463c009c2d0c5dd35c0d982599 + React-CoreModules: 1f6d1744b5f9f2ec684a4bb5ced25370f87e5382 + React-cxxreact: 3af79478e8187b63ffc22b794cd42d3fc1f1f2da + React-debug: 6328c2228e268846161f10082e80dc69eac2e90a + React-defaultsnativemodule: d635ef36d755321e5d6fc065bd166b2c5a0e9833 + React-domnativemodule: dd28f6d96cd21236e020be2eff6fe0b7d4ec3b66 + React-Fabric: 2e32c3fdbb1fbcf5fde54607e3abe453c6652ce2 + React-FabricComponents: 5ed0cdb81f6b91656cb4d3be432feaa28a58071a + React-FabricImage: 2bc714f818cb24e454f5d3961864373271b2faf8 + React-featureflags: 847642f41fa71ad4eec5e0351badebcad4fe6171 + React-featureflagsnativemodule: c868a544b2c626fa337bcbd364b1befe749f0d3f + React-graphics: 192ec701def5b3f2a07db2814dfba5a44986cff6 + React-hermes: e875778b496c86d07ab2ccaa36a9505d248a254b + React-idlecallbacksnativemodule: 4d57965cdf82c14ee3b337189836cd8491632b76 + React-ImageManager: bd0b99e370b13de82c9cd15f0f08144ff3de079e + React-jserrorhandler: a2fdef4cbcfdcdf3fa9f5d1f7190f7fd4535248d + React-jsi: 89d43d1e7d4d0663f8ba67e0b39eb4e4672c27de + React-jsiexecutor: abe4874aaab90dfee5dec480680220b2f8af07e3 + React-jsinspector: a0b3e051aef842b0b2be2353790ae2b2a5a65a8f + React-jsinspectorcdp: 6346013b2247c6263fbf5199adf4a8751e53bd89 + React-jsinspectornetwork: 26281aa50d49fc1ec93abf981d934698fa95714f + React-jsinspectortracing: 55eedf6d57540507570259a778663b90060bbd6e + React-jsitooling: 0e001113fa56d8498aa8ac28437ac0d36348e51a + React-jsitracing: b713793eb8a5bbc4d86a84e9d9e5023c0f58cbaf + React-logger: 50fdb9a8236da90c0b1072da5c32ee03aeb5bf28 + React-Mapbuffer: 9050ee10c19f4f7fca8963d0211b2854d624973e + React-microtasksnativemodule: f775db9e991c6f3b8ccbc02bfcde22770f96e23b + react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2 + React-NativeModulesApple: 8969913947d5b576de4ed371a939455a8daf28aa + React-oscompat: ce47230ed20185e91de62d8c6d139ae61763d09c + React-perflogger: 02b010e665772c7dcb859d85d44c1bfc5ac7c0e4 + React-performancetimeline: 130db956b5a83aa4fb41ddf5ae68da89f3fb1526 + React-RCTActionSheet: 0b14875b3963e9124a5a29a45bd1b22df8803916 + React-RCTAnimation: a7b90fd2af7bb9c084428867445a1481a8cb112e + React-RCTAppDelegate: 3262bedd01263f140ec62b7989f4355f57cec016 + React-RCTBlob: c17531368702f1ebed5d0ada75a7cf5915072a53 + React-RCTFabric: 6409edd8cfdc3133b6cc75636d3b858fdb1d11ea + React-RCTFBReactNativeSpec: c004b27b4fa3bd85878ad2cf53de3bbec85da797 + React-RCTImage: c68078a120d0123f4f07a5ac77bea3bb10242f32 + React-RCTLinking: cf8f9391fe7fe471f96da3a5f0435235eca18c5b + React-RCTNetwork: ca31f7c879355760c2d9832a06ee35f517938a20 + React-RCTRuntime: a6cf4a1e42754fc87f493e538f2ac6b820e45418 + React-RCTSettings: e0e140b2ff4bf86d34e9637f6316848fc00be035 + React-RCTText: 75915bace6f7877c03a840cc7b6c622fb62bfa6b + React-RCTVibration: 25f26b85e5e432bb3c256f8b384f9269e9529f25 + React-rendererconsistency: 2dac03f448ff337235fd5820b10f81633328870d + React-renderercss: 477da167bb96b5ac86d30c5d295412fb853f5453 + React-rendererdebug: 2a1798c6f3ef5f22d466df24c33653edbabb5b89 + React-RuntimeApple: 28cf4d8eb18432f6a21abbed7d801ab7f6b6f0b4 + React-RuntimeCore: 41bf0fd56a00de5660f222415af49879fa49c4f0 + React-runtimeexecutor: 1afb774dde3011348e8334be69d2f57a359ea43e + React-RuntimeHermes: f3b158ea40e8212b1a723a68b4315e7a495c5fc6 + React-runtimescheduler: 3e1e2bec7300bae512533107d8e54c6e5c63fe0f + React-timing: 6fa9883de2e41791e5dc4ec404e5e37f3f50e801 + React-utils: 6e2035b53d087927768649a11a26c4e092448e34 + ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 + ReactCodegen: 7d4593f7591f002d137fe40cef3f6c11f13c88cc + ReactCommon: 08810150b1206cc44aecf5f6ae19af32f29151a8 + ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a + ReactNativeHealthkit: 3cdb65158c3ba0effe017149ee010f785c59ac40 + RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 + RNGestureHandler: 2914750df066d89bf9d8f48a10ad5f0051108ac3 + RNReanimated: e5c702a3e24cc1c68b2de67671713f35461678f4 + RNScreens: d8d6f1792f6e7ac12b0190d33d8d390efc0c1845 + RNWorklets: 76fce72926e28e304afb44f0da23b2d24f2c1fa0 + SDWebImage: 1bb6a1b84b6fe87b972a102bdc77dd589df33477 + SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 + SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 + Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a + +PODFILE CHECKSUM: 44061434db8e1b9d2590f8fda69c1c7e0ff94572 + +COCOAPODS: 1.16.2 diff --git a/homeflow/ios/Podfile.properties.json b/homeflow/ios/Podfile.properties.json new file mode 100644 index 0000000..663aaac --- /dev/null +++ b/homeflow/ios/Podfile.properties.json @@ -0,0 +1,8 @@ +{ + "expo.jsEngine": "hermes", + "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", + "newArchEnabled": "true", + "ios.deploymentTarget": "16.0", + "ios.forceStaticLinking": "[]", + "apple.privacyManifestAggregationEnabled": "true" +} diff --git a/homeflow/lib/chat/chatHelperPlaybook.ts b/homeflow/lib/chat/chatHelperPlaybook.ts new file mode 100644 index 0000000..66a3b44 --- /dev/null +++ b/homeflow/lib/chat/chatHelperPlaybook.ts @@ -0,0 +1,195 @@ +// --- Types --- + +export type CheckpointType = 'YES_NO' | 'FREE_TEXT'; + +export interface FlowStep { + id: string; + botMessage: string; + checkpoint?: { + question: string; + type: CheckpointType; + onYes: string; // next step id, or 'DONE' + onNo: { + hint: string; + retryStepId: string; + }; + }; +} + +export interface GuidedFlow { + id: string; + name: string; + description: string; + steps: FlowStep[]; +} + +export interface QuickAction { + label: string; + flowId: string | null; + comingSoon?: boolean; +} + +export interface IntentPattern { + id: string; + patterns: RegExp[]; + response: string; + type: 'medical_refusal' | 'throne_coming_soon'; +} + +// --- Data --- + +export const GREETING = + "Hi there! I'm your setup assistant for the HomeFlow study. I can help you set up your Apple Watch, troubleshoot syncing, or answer questions about the app. What would you like help with?"; + +export const QUICK_ACTIONS: QuickAction[] = [ + { label: 'Set up Apple Watch', flowId: 'apple-watch-setup' }, + { label: 'Fix syncing', flowId: 'fix-syncing' }, + { label: 'Throne setup', flowId: null, comingSoon: true }, +]; + +export const GUIDED_FLOWS: Record = { + 'apple-watch-setup': { + id: 'apple-watch-setup', + name: 'Apple Watch Setup', + description: 'Walk through pairing your Apple Watch and enabling Health data sharing.', + steps: [ + { + id: 'aw-1', + botMessage: + "Let's get your Apple Watch set up with HomeFlow. First, we need to make sure your watch is paired with your iPhone.", + checkpoint: { + question: 'Is your Apple Watch currently paired with your iPhone?', + type: 'YES_NO', + onYes: 'aw-3', + onNo: { + hint: 'No worries. Open the Settings app on your iPhone, tap Bluetooth, and make sure it\'s turned on. Then open the Watch app on your iPhone and follow the on-screen instructions to pair your Apple Watch. Let me know when you\'re ready to try again.', + retryStepId: 'aw-1', + }, + }, + }, + // aw-2 is skipped (reserved for future expansion) + { + id: 'aw-3', + botMessage: + "Great — your watch is paired. Now let's make sure the Health app is accessible on your iPhone.", + checkpoint: { + question: 'Can you open the Health app on your iPhone?', + type: 'YES_NO', + onYes: 'aw-5', + onNo: { + hint: 'The Health app comes pre-installed on every iPhone. Look for a white icon with a red heart. If you can\'t find it, try swiping down on your Home Screen and searching for "Health." Let me know once you have it open.', + retryStepId: 'aw-3', + }, + }, + }, + { + id: 'aw-5', + botMessage: + "Now let's enable data sharing so HomeFlow can read your health data. In the Health app, tap your profile picture in the top-right corner, then tap \"Apps\" and find HomeFlow. Turn on all the data categories listed (steps, heart rate, sleep, and active energy).", + checkpoint: { + question: 'Did you turn on the data categories for HomeFlow?', + type: 'YES_NO', + onYes: 'DONE', + onNo: { + hint: 'That\'s okay. Open the Health app → tap your profile picture (top-right) → tap "Apps" → tap "HomeFlow." You should see a list of data categories with toggles. Turn them all on, then let me know.', + retryStepId: 'aw-5', + }, + }, + }, + ], + }, + 'fix-syncing': { + id: 'fix-syncing', + name: 'Fix Syncing', + description: 'Troubleshoot Apple Watch or Health data syncing issues.', + steps: [ + { + id: 'sync-1', + botMessage: + "Let's troubleshoot your syncing issue. First, make sure your Apple Watch is on your wrist and unlocked.", + checkpoint: { + question: 'Is your Apple Watch on your wrist and unlocked?', + type: 'YES_NO', + onYes: 'sync-3', + onNo: { + hint: 'Put your Apple Watch on your wrist and tap the screen or press the side button to wake it. Enter your passcode if prompted. Let me know when it\'s on and unlocked.', + retryStepId: 'sync-1', + }, + }, + }, + { + id: 'sync-3', + botMessage: + 'Good. Now try opening the Health app on your iPhone and pulling down on the Summary screen to refresh your data.', + checkpoint: { + question: 'Do you see updated data in the Health app?', + type: 'YES_NO', + onYes: 'DONE', + onNo: { + hint: 'Try restarting both your Apple Watch (press and hold the side button → Power Off → turn back on) and your iPhone. After they restart, open the Health app again and pull down to refresh. This usually resolves syncing delays.', + retryStepId: 'sync-3', + }, + }, + }, + ], + }, +}; + +export const INTENT_PATTERNS: IntentPattern[] = [ + { + id: 'medical-refusal', + patterns: [ + /symptom/i, + /diagnos/i, + /\bnormal\b/i, + /worry/i, + /\bpain\b/i, + /blood/i, + /\bhurt/i, + /medication/i, + /treatment/i, + /side effect/i, + /prescri/i, + /dosage/i, + ], + response: + "I'm not able to provide medical advice. Please reach out to your care team or physician for health-related questions. I'm here to help with device setup and syncing — would you like help with that?", + type: 'medical_refusal', + }, + { + id: 'throne-coming-soon', + patterns: [ + /throne/i, + /uroflow/i, + /toilet/i, + /void.*device/i, + /flow.*sensor/i, + ], + response: + 'Throne setup guidance is coming soon. For now, please follow the instructions included with your device or from your study team. I can help you set up your Apple Watch or Apple Health in the meantime.', + type: 'throne_coming_soon', + }, +]; + +export const FLOW_COMPLETE_MESSAGE = + "You're all set! Everything looks good. If you run into any issues later, just come back here and I can help you troubleshoot."; + +export const FOLLOW_UP_PROMPT = 'Is there anything else I can help with?'; +export const FAREWELL_MESSAGE = + "Sounds good! I'll be right here whenever you need me."; +export const FOLLOW_UP_YES_MESSAGE = + "Of course! Let's see what else I can help with."; + +export const CONCIERGE_SYSTEM_PROMPT = `You are a calm, friendly setup concierge for the HomeFlow app, part of a BPH (benign prostatic hyperplasia) research study at Stanford. + +Your role: +- Help users set up their Apple Watch and Apple Health with the HomeFlow app +- Answer questions about the app, the study schedule, and how data collection works +- Keep responses short (2-3 sentences), calm, and easy to read + +Rules you must follow: +- Never provide medical advice of any kind. If asked about symptoms, diagnoses, medications, or treatments, say: "I'm not able to provide medical advice. Please reach out to your care team." +- If asked about Throne or uroflow devices, say: "Throne setup guidance is coming soon. Please follow the instructions from your study team." +- Never mention Apple Health UI details like rings, goals, or badges +- Never speculate about the user's health condition +- Stay focused on setup, syncing, and app usage`; diff --git a/homeflow/lib/chat/useConciergeChat.ts b/homeflow/lib/chat/useConciergeChat.ts new file mode 100644 index 0000000..6c320f6 --- /dev/null +++ b/homeflow/lib/chat/useConciergeChat.ts @@ -0,0 +1,482 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; +import type { ChatMessage, ChatProvider } from '@spezivibe/chat'; +import { streamChatCompletion } from '@spezivibe/chat'; +import type { CheckpointType, QuickAction } from './chatHelperPlaybook'; +import { + GREETING, + QUICK_ACTIONS, + GUIDED_FLOWS, + INTENT_PATTERNS, + FLOW_COMPLETE_MESSAGE, + FOLLOW_UP_PROMPT, + FAREWELL_MESSAGE, + FOLLOW_UP_YES_MESSAGE, + CONCIERGE_SYSTEM_PROMPT, +} from './chatHelperPlaybook'; + +interface ActiveFlow { + flowId: string; + stepIndex: number; +} + +export interface ActiveCheckpoint { + type: CheckpointType; +} + +function makeId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +// ms between each word during typewriter animation +const WORD_DELAY = 35; + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function useConciergeChat(provider: ChatProvider | null) { + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [input, setInput] = useState(''); + const [activeFlow, setActiveFlow] = useState(null); + const [showQuickActions, setShowQuickActions] = useState(true); + const [activeCheckpoint, setActiveCheckpoint] = + useState(null); + const [isAnimating, setIsAnimating] = useState(false); + const [awaitingFollowUp, setAwaitingFollowUp] = useState(false); + + const abortRef = useRef(null); + const animTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + const busyRef = useRef(false); + const initRef = useRef(false); + + // Refs for reading latest state inside async flows + const activeFlowRef = useRef(activeFlow); + const activeCheckpointRef = useRef(activeCheckpoint); + const awaitingFollowUpRef = useRef(awaitingFollowUp); + const messagesRef = useRef(messages); + + activeFlowRef.current = activeFlow; + activeCheckpointRef.current = activeCheckpoint; + awaitingFollowUpRef.current = awaitingFollowUp; + messagesRef.current = messages; + + // Cleanup on unmount + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (animTimerRef.current) clearInterval(animTimerRef.current); + }; + }, []); + + // --- Typewriter animation --- + const animateBotMessage = useCallback((text: string): Promise => { + return new Promise((resolve) => { + if (!mountedRef.current) { + resolve(); + return; + } + + const words = text.split(' '); + const msgId = makeId(); + let wordIndex = 1; + + setIsAnimating(true); + // Start with first word immediately + setMessages((prev) => [ + ...prev, + { id: msgId, role: 'assistant' as const, content: words[0] || '' }, + ]); + + if (words.length <= 1) { + setIsAnimating(false); + resolve(); + return; + } + + const timer = setInterval(() => { + if (!mountedRef.current || wordIndex >= words.length) { + clearInterval(timer); + animTimerRef.current = null; + // Ensure final content is exact + setMessages((prev) => + prev.map((m) => + m.id === msgId ? { ...m, content: text } : m, + ), + ); + setIsAnimating(false); + resolve(); + return; + } + + wordIndex++; + const partial = words.slice(0, wordIndex).join(' '); + setMessages((prev) => + prev.map((m) => + m.id === msgId ? { ...m, content: partial } : m, + ), + ); + }, WORD_DELAY); + + animTimerRef.current = timer; + }); + }, []); + + // --- Helpers --- + + const addUserMessage = useCallback((text: string) => { + setMessages((prev) => [ + ...prev, + { id: makeId(), role: 'user' as const, content: text }, + ]); + }, []); + + const matchIntent = useCallback((text: string): string | null => { + for (const intent of INTENT_PATTERNS) { + for (const pattern of intent.patterns) { + if (pattern.test(text)) return intent.response; + } + } + return null; + }, []); + + const getStepById = useCallback((flowId: string, stepId: string) => { + const flow = GUIDED_FLOWS[flowId]; + return flow?.steps.find((s) => s.id === stepId) ?? null; + }, []); + + // --- Follow-up & reset --- + + const askFollowUp = useCallback(async () => { + await wait(250); + if (!mountedRef.current) return; + await animateBotMessage(FOLLOW_UP_PROMPT); + setAwaitingFollowUp(true); + setActiveCheckpoint({ type: 'YES_NO' }); + }, [animateBotMessage]); + + const resetConversation = useCallback(async () => { + await wait(800); + if (!mountedRef.current) return; + + setMessages([]); + setActiveFlow(null); + setActiveCheckpoint(null); + setAwaitingFollowUp(false); + setShowQuickActions(true); + + await wait(250); + if (!mountedRef.current) return; + await animateBotMessage(GREETING); + }, [animateBotMessage]); + + // --- Flow navigation --- + + const advanceToStep = useCallback( + async (flowId: string, nextStepId: string) => { + if (nextStepId === 'DONE') { + setActiveFlow(null); + setActiveCheckpoint(null); + await animateBotMessage(FLOW_COMPLETE_MESSAGE); + await askFollowUp(); + return; + } + + const flow = GUIDED_FLOWS[flowId]; + if (!flow) return; + + const nextIndex = flow.steps.findIndex((s) => s.id === nextStepId); + if (nextIndex === -1) return; + + const nextStep = flow.steps[nextIndex]; + setActiveFlow({ flowId, stepIndex: nextIndex }); + + await animateBotMessage(nextStep.botMessage); + + if (nextStep.checkpoint) { + await wait(150); + if (!mountedRef.current) return; + await animateBotMessage(nextStep.checkpoint.question); + setActiveCheckpoint({ type: nextStep.checkpoint.type }); + } else { + setActiveCheckpoint(null); + } + }, + [animateBotMessage, askFollowUp], + ); + + const handleCheckpoint = useCallback( + async (isYes: boolean) => { + const flow = activeFlowRef.current; + if (!flow) return; + + const flowData = GUIDED_FLOWS[flow.flowId]; + if (!flowData) return; + + const step = flowData.steps[flow.stepIndex]; + if (!step?.checkpoint) return; + + setActiveCheckpoint(null); + + if (isYes) { + await advanceToStep(flow.flowId, step.checkpoint.onYes); + } else { + await animateBotMessage(step.checkpoint.onNo.hint); + + const retryStep = getStepById( + flow.flowId, + step.checkpoint.onNo.retryStepId, + ); + if (retryStep?.checkpoint) { + await wait(150); + if (!mountedRef.current) return; + await animateBotMessage(retryStep.checkpoint.question); + setActiveCheckpoint({ type: retryStep.checkpoint.type }); + } + } + }, + [advanceToStep, animateBotMessage, getStepById], + ); + + // --- LLM fallback --- + + const callLLM = useCallback( + async (userText: string) => { + if (!provider) { + await animateBotMessage( + "I can help with guided setup flows, but free-text questions need an API key. Try one of the setup options, or ask your study coordinator.", + ); + await askFollowUp(); + return; + } + + setIsLoading(true); + const assistantMsgId = makeId(); + setMessages((prev) => [ + ...prev, + { id: assistantMsgId, role: 'assistant' as const, content: '' }, + ]); + + const abort = new AbortController(); + abortRef.current = abort; + + const currentMessages = messagesRef.current; + const llmMessages = [ + { role: 'system' as const, content: CONCIERGE_SYSTEM_PROMPT }, + ...currentMessages + .filter((m) => m.role !== 'system') + .map((m) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + { role: 'user' as const, content: userText }, + ]; + + await streamChatCompletion( + llmMessages, + provider, + { + onToken: (token: string) => { + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { ...m, content: m.content + token } + : m, + ), + ); + }, + onComplete: () => { + setIsLoading(false); + abortRef.current = null; + }, + onError: () => { + setMessages((prev) => + prev.map((m) => + m.id === assistantMsgId + ? { + ...m, + content: + 'Sorry, something went wrong. Please try again.', + } + : m, + ), + ); + setIsLoading(false); + abortRef.current = null; + }, + }, + abort.signal, + ); + + // After LLM finishes, ask follow-up + await askFollowUp(); + }, + [provider, animateBotMessage, askFollowUp], + ); + + // --- Public API --- + + const sendMessage = useCallback( + (text: string) => { + const trimmed = text.trim(); + if (!trimmed || busyRef.current || isLoading) return; + + addUserMessage(trimmed); + setShowQuickActions(false); + setInput(''); + + const process = async () => { + busyRef.current = true; + try { + // 1. Follow-up response (yes/no to "anything else?") + if (awaitingFollowUpRef.current) { + setAwaitingFollowUp(false); + setActiveCheckpoint(null); + + const lower = trimmed.toLowerCase(); + const isYes = ['yes', 'y', 'yep', 'yeah', 'sure', 'please'].includes(lower); + const isNo = [ + 'no', 'n', 'nope', 'nah', "i'm good", 'im good', + 'thanks', 'thank you', 'no thanks', + ].includes(lower); + + if (isYes) { + await animateBotMessage(FOLLOW_UP_YES_MESSAGE); + await wait(150); + if (!mountedRef.current) return; + await animateBotMessage('What else can I help with?'); + setShowQuickActions(true); + return; + } + if (isNo) { + await animateBotMessage(FAREWELL_MESSAGE); + await resetConversation(); + return; + } + // Not a clear yes/no — treat as a new question, fall through + } + + // 2. Intent patterns (medical refusal, Throne coming soon) + const intentResponse = matchIntent(trimmed); + if (intentResponse) { + await wait(150); + setActiveFlow(null); + await animateBotMessage(intentResponse); + await askFollowUp(); + return; + } + + // 3. Active flow checkpoint + if ( + activeFlowRef.current && + activeCheckpointRef.current?.type === 'YES_NO' + ) { + const lower = trimmed.toLowerCase(); + const isYes = ['yes', 'y', 'yep', 'yeah'].includes(lower); + const isNo = ['no', 'n', 'nope', 'nah'].includes(lower); + + if (isYes) { + await handleCheckpoint(true); + return; + } + if (isNo) { + await handleCheckpoint(false); + return; + } + // Typed something else during checkpoint — fall through to LLM + } + + // 4. LLM fallback + if (activeFlowRef.current) { + setActiveFlow(null); + setActiveCheckpoint(null); + } + await callLLM(trimmed); + } finally { + busyRef.current = false; + } + }; + + process(); + }, + [ + isLoading, + addUserMessage, + matchIntent, + animateBotMessage, + handleCheckpoint, + callLLM, + askFollowUp, + resetConversation, + ], + ); + + const startFlow = useCallback( + (flowId: string) => { + if (busyRef.current || isLoading) return; + + const flow = GUIDED_FLOWS[flowId]; + if (!flow) return; + + setShowQuickActions(false); + setActiveFlow({ flowId, stepIndex: 0 }); + + const process = async () => { + busyRef.current = true; + try { + const firstStep = flow.steps[0]; + await animateBotMessage(firstStep.botMessage); + + if (firstStep.checkpoint) { + await wait(150); + if (!mountedRef.current) return; + await animateBotMessage(firstStep.checkpoint.question); + setActiveCheckpoint({ type: firstStep.checkpoint.type }); + } + } finally { + busyRef.current = false; + } + }; + + process(); + }, + [isLoading, animateBotMessage], + ); + + const handleStop = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setIsLoading(false); + }, []); + + // Initialize: animate greeting on mount (guarded for strict mode) + useEffect(() => { + if (initRef.current) return; + initRef.current = true; + const init = async () => { + busyRef.current = true; + await animateBotMessage(GREETING); + busyRef.current = false; + }; + init(); + }, [animateBotMessage]); + + const quickActions: QuickAction[] | null = showQuickActions + ? QUICK_ACTIONS + : null; + + return { + messages, + isLoading, + isAnimating, + input, + setInput, + sendMessage, + startFlow, + activeCheckpoint, + quickActions, + handleStop, + }; +} diff --git a/homeflow/lib/consent/consent-document.ts b/homeflow/lib/consent/consent-document.ts index f71e785..bc24ec6 100644 --- a/homeflow/lib/consent/consent-document.ts +++ b/homeflow/lib/consent/consent-document.ts @@ -153,6 +153,36 @@ export function getAllSections(): ConsentSection[] { return CONSENT_DOCUMENT.sections; } +/** + * Short profile-friendly summary of what the user consented to. + * Derived from the 'purpose' and 'privacy' sections above. + */ +export const CONSENT_PROFILE_SUMMARY = + `This study evaluates whether relief of bladder outlet obstruction improves daily activity, sleep, and urinary flow patterns using wearable devices and home uroflow measurement. ` + + `Your data is handled securely, used only for research purposes, and de-identified where possible. ` + + `You may withdraw at any time without affecting your medical care.`; + +/** + * Short profile-friendly summary of what data the app can access. + * Derived from the 'procedures' and 'hipaa' sections above. + */ +export const DATA_PERMISSIONS_SUMMARY = [ + 'Daily activity and sleep data from your Apple Watch or wearable device', + 'Urinary flow measurements from the Throne uroflow device', + 'Survey responses about symptoms and medical history', + 'Medical information shared through Apple Health with your permission', +]; + +/** + * Study coordinator contact info for the Profile screen. + */ +export const STUDY_COORDINATOR = { + name: 'HomeFlow Study Team', + role: 'Study Coordinator', + email: STUDY_INFO.contactEmail, + phone: STUDY_INFO.contactPhone, +} as const; + /** * Generate a summary of consent for confirmation */ diff --git a/homeflow/lib/constants.ts b/homeflow/lib/constants.ts index 11ea8dc..c15c731 100644 --- a/homeflow/lib/constants.ts +++ b/homeflow/lib/constants.ts @@ -9,6 +9,7 @@ export const STORAGE_KEYS = { // Onboarding ONBOARDING_STEP: '@homeflow_onboarding_step', ONBOARDING_DATA: '@homeflow_onboarding_data', + ONBOARDING_FINISHED: '@homeflow_onboarding_finished', // Consent CONSENT_GIVEN: '@homeflow_consent_given', @@ -40,9 +41,11 @@ export const CONSENT_KEY = '@consent_given'; */ export enum OnboardingStep { WELCOME = 'welcome', - CHAT = 'chat', // Combined eligibility + medical history + CHAT = 'chat', // Eligibility screening CONSENT = 'consent', PERMISSIONS = 'permissions', + HEALTH_DATA_TEST = 'health_data_test', // Dev-only: test HealthKit + Clinical Records queries + MEDICAL_HISTORY = 'medical_history', // Medical history collection (chatbot) BASELINE_SURVEY = 'baseline_survey', COMPLETE = 'complete', } @@ -55,6 +58,8 @@ export const ONBOARDING_FLOW: OnboardingStep[] = [ OnboardingStep.CHAT, OnboardingStep.CONSENT, OnboardingStep.PERMISSIONS, + OnboardingStep.HEALTH_DATA_TEST, + OnboardingStep.MEDICAL_HISTORY, OnboardingStep.BASELINE_SURVEY, OnboardingStep.COMPLETE, ]; diff --git a/homeflow/lib/healthkit-config.ts b/homeflow/lib/healthkit-config.ts deleted file mode 100644 index b96d92a..0000000 --- a/homeflow/lib/healthkit-config.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * HealthKit Configuration - * - * Configure which health data types to collect and display. - * Modify this file to customize the health metrics for your app. - */ - -import { HealthKitConfig, SampleType } from '@spezivibe/healthkit'; - -export const healthKitConfig: HealthKitConfig = { - // Health data types to collect (request read/write access) - collect: [ - SampleType.stepCount, - SampleType.heartRate, - SampleType.activeEnergyBurned, - SampleType.sleepAnalysis, - ], - - // Health data types to read only (no write access) - readOnly: [ - SampleType.bodyMass, - SampleType.height, - ], - - // Enable background delivery for these types (optional) - // backgroundDelivery: [ - // SampleType.stepCount, - // ], - - // Whether to sync health data to the backend (optional) - syncToBackend: false, -}; diff --git a/homeflow/lib/services/__tests__/onboarding-service.test.ts b/homeflow/lib/services/__tests__/onboarding-service.test.ts index 7a66941..faf1b37 100644 --- a/homeflow/lib/services/__tests__/onboarding-service.test.ts +++ b/homeflow/lib/services/__tests__/onboarding-service.test.ts @@ -5,7 +5,7 @@ * enrollment flow for the research study. It tracks which step the user * is on, stores collected data, and persists state so users can resume. * - * Onboarding steps: WELCOME → CHAT → CONSENT → PERMISSIONS → BASELINE_SURVEY → COMPLETE + * Onboarding steps: WELCOME → CHAT → CONSENT → PERMISSIONS → MEDICAL_HISTORY → BASELINE_SURVEY → COMPLETE * * Key behaviors tested: * - State machine navigation (start, nextStep, goToStep, complete) @@ -136,6 +136,7 @@ describe('OnboardingService', () => { it('should return true when step is COMPLETE', async () => { (AsyncStorage.getItem as jest.Mock).mockImplementation((key: string) => { if (key === STORAGE_KEYS.ONBOARDING_STEP) return Promise.resolve(OnboardingStep.COMPLETE); + if (key === STORAGE_KEYS.ONBOARDING_FINISHED) return Promise.resolve('true'); return Promise.resolve(null); }); @@ -467,6 +468,7 @@ describe('OnboardingService', () => { expect(AsyncStorage.multiRemove).toHaveBeenCalledWith([ STORAGE_KEYS.ONBOARDING_STEP, STORAGE_KEYS.ONBOARDING_DATA, + STORAGE_KEYS.ONBOARDING_FINISHED, STORAGE_KEYS.CONSENT_GIVEN, STORAGE_KEYS.CONSENT_DATE, STORAGE_KEYS.CONSENT_VERSION, @@ -541,8 +543,8 @@ describe('OnboardingService', () => { await service.initialize(); const progress = service.getProgress(); - // CONSENT is index 2 in a 6-step flow (indices 0-5) - // Progress = (2 / 5) * 100 = 40% + // CONSENT is index 2 in a 7-step flow (indices 0-6) + // Progress = (2 / 6) * 100 = 33% const expectedIndex = ONBOARDING_FLOW.indexOf(OnboardingStep.CONSENT); const expectedProgress = Math.round((expectedIndex / (ONBOARDING_FLOW.length - 1)) * 100); expect(progress).toBe(expectedProgress); diff --git a/homeflow/lib/services/fhir/codes.ts b/homeflow/lib/services/fhir/codes.ts new file mode 100644 index 0000000..0c92ac6 --- /dev/null +++ b/homeflow/lib/services/fhir/codes.ts @@ -0,0 +1,144 @@ +/** + * Medical Code Constants + * + * LOINC, SNOMED CT, ICD-10, and drug name mappings for + * classifying clinical records into BPH-relevant categories. + */ + +import type { BPHDrugClass } from './types'; + +// ── LOINC Codes (Lab Tests) ───────────────────────────────────────── + +export const LOINC = { + PSA: '2857-1', + HBA1C: '4548-4', + URINALYSIS_PANEL: '24356-8', + PVR: '9187-6', + UROFLOW_QMAX: '80963-5', +} as const; + +// ── SNOMED CT Codes (Conditions) ──────────────────────────────────── + +export const SNOMED = { + DIABETES: '73211009', + HYPERTENSION: '38341003', + BPH: '266569009', + BPH_ALT: '16940007', +} as const; + +// ── ICD-10 Code Prefixes (Conditions) ─────────────────────────────── + +export const ICD10_PREFIXES = { + DIABETES_TYPE1: 'E10', + DIABETES_TYPE2: 'E11', + DIABETES_OTHER: 'E13', + HYPERTENSION: ['I10', 'I11', 'I12', 'I13', 'I14', 'I15'], + BPH: 'N40', +} as const; + +// ── BPH Drug Classifications ─────────────────────────────────────── + +interface DrugEntry { + generic: string; + brands: string[]; + class: BPHDrugClass; +} + +export const BPH_DRUGS: DrugEntry[] = [ + // Alpha blockers + { generic: 'tamsulosin', brands: ['flomax'], class: 'alpha_blocker' }, + { generic: 'alfuzosin', brands: ['uroxatral'], class: 'alpha_blocker' }, + { generic: 'silodosin', brands: ['rapaflo'], class: 'alpha_blocker' }, + { generic: 'doxazosin', brands: ['cardura'], class: 'alpha_blocker' }, + { generic: 'terazosin', brands: ['hytrin'], class: 'alpha_blocker' }, + + // 5-alpha reductase inhibitors + { generic: 'finasteride', brands: ['proscar', 'propecia'], class: 'five_ari' }, + { generic: 'dutasteride', brands: ['avodart'], class: 'five_ari' }, + + // Anticholinergics + { generic: 'oxybutynin', brands: ['ditropan'], class: 'anticholinergic' }, + { generic: 'tolterodine', brands: ['detrol'], class: 'anticholinergic' }, + { generic: 'solifenacin', brands: ['vesicare'], class: 'anticholinergic' }, + { generic: 'darifenacin', brands: ['enablex'], class: 'anticholinergic' }, + { generic: 'trospium', brands: ['sanctura'], class: 'anticholinergic' }, + { generic: 'fesoterodine', brands: ['toviaz'], class: 'anticholinergic' }, + + // Beta-3 agonists + { generic: 'mirabegron', brands: ['myrbetriq'], class: 'beta3_agonist' }, + { generic: 'vibegron', brands: ['gemtesa'], class: 'beta3_agonist' }, +]; + +/** Map of all drug names (lower case) → DrugEntry for fast lookup */ +export const DRUG_NAME_MAP: Map = (() => { + const map = new Map(); + for (const drug of BPH_DRUGS) { + map.set(drug.generic, drug); + for (const brand of drug.brands) { + map.set(brand, drug); + } + } + return map; +})(); + +// ── BPH Procedure Keywords ───────────────────────────────────────── + +export const BPH_PROCEDURE_KEYWORDS = [ + 'turp', + 'transurethral resection', + 'holep', + 'holmium laser', + 'greenlight', + 'green light', + 'photoselective vaporization', + 'pvp', + 'urolift', + 'prostatic urethral lift', + 'rezum', + 'water vapor', + 'aquablation', + 'simple prostatectomy', + 'prostatectomy', + 'bladder outlet', +] as const; + +// ── Condition Text Patterns ───────────────────────────────────────── + +export const CONDITION_TEXT_PATTERNS = { + diabetes: [ + 'diabetes', + 'diabetic', + 'dm type', + 'dm2', + 'dm1', + 'type 2 dm', + 'type 1 dm', + 'hyperglycemia', + 'a1c', + ], + hypertension: [ + 'hypertension', + 'hypertensive', + 'high blood pressure', + 'htn', + 'elevated blood pressure', + ], + bph: [ + 'benign prostatic hyperplasia', + 'benign prostatic hypertrophy', + 'enlarged prostate', + 'bph', + 'bladder outlet obstruction', + 'lower urinary tract symptoms', + 'luts', + 'prostate enlargement', + ], +} as const; + +// ── Lab Text Patterns ─────────────────────────────────────────────── + +export const LAB_TEXT_PATTERNS = { + psa: ['psa', 'prostate specific antigen', 'prostate-specific antigen'], + hba1c: ['hba1c', 'hemoglobin a1c', 'glycated hemoglobin', 'a1c', 'glycohemoglobin'], + urinalysis: ['urinalysis', 'urine analysis', 'ua ', 'u/a'], +} as const; diff --git a/homeflow/lib/services/fhir/condition-mapper.ts b/homeflow/lib/services/fhir/condition-mapper.ts new file mode 100644 index 0000000..2412791 --- /dev/null +++ b/homeflow/lib/services/fhir/condition-mapper.ts @@ -0,0 +1,127 @@ +/** + * Condition Mapper + * + * Maps clinical condition records to known categories: + * diabetes, hypertension, BPH, or other. + * Uses SNOMED/ICD-10 codes first, then text-based fallback. + */ + +import type { MappedCondition, PrefillSource, KnownCondition, NormalizedCondition } from './types'; +import { SNOMED, ICD10_PREFIXES, CONDITION_TEXT_PATTERNS } from './codes'; +import { parseConditionRecord } from './fhir-parser'; + +type ClinicalCondRecord = { + displayName: string; + fhirResource?: Record; +}; + +function matchCodeToCategory( + code: string | undefined, + system: string | undefined, +): { category: KnownCondition; matchedCode: string } | null { + if (!code) return null; + + const isSNOMED = system?.includes('snomed') ?? false; + const isICD10 = system?.includes('icd') ?? false; + + // SNOMED matches + if (isSNOMED || !system) { + if (code === SNOMED.DIABETES) return { category: 'diabetes', matchedCode: `SNOMED|${code}` }; + if (code === SNOMED.HYPERTENSION) return { category: 'hypertension', matchedCode: `SNOMED|${code}` }; + if (code === SNOMED.BPH || code === SNOMED.BPH_ALT) return { category: 'bph', matchedCode: `SNOMED|${code}` }; + } + + // ICD-10 matches (prefix-based) + if (isICD10 || !system) { + const upper = code.toUpperCase(); + if (upper.startsWith(ICD10_PREFIXES.DIABETES_TYPE1) || + upper.startsWith(ICD10_PREFIXES.DIABETES_TYPE2) || + upper.startsWith(ICD10_PREFIXES.DIABETES_OTHER)) { + return { category: 'diabetes', matchedCode: `ICD-10|${code}` }; + } + for (const prefix of ICD10_PREFIXES.HYPERTENSION) { + if (upper.startsWith(prefix)) { + return { category: 'hypertension', matchedCode: `ICD-10|${code}` }; + } + } + if (upper.startsWith(ICD10_PREFIXES.BPH)) { + return { category: 'bph', matchedCode: `ICD-10|${code}` }; + } + } + + return null; +} + +function matchTextToCategory(name: string): KnownCondition { + const lower = name.toLowerCase(); + + for (const pattern of CONDITION_TEXT_PATTERNS.diabetes) { + if (lower.includes(pattern)) return 'diabetes'; + } + for (const pattern of CONDITION_TEXT_PATTERNS.hypertension) { + if (lower.includes(pattern)) return 'hypertension'; + } + for (const pattern of CONDITION_TEXT_PATTERNS.bph) { + if (lower.includes(pattern)) return 'bph'; + } + + return 'other'; +} + +function mapSingleCondition( + normalized: NormalizedCondition, + displayName: string, +): MappedCondition { + const condName = normalized.name || displayName; + + // Try code-based match + const codeMatch = matchCodeToCategory(normalized.code?.code, normalized.code?.system); + if (codeMatch) { + const source: PrefillSource = { + type: 'clinical_record', + displayName: condName, + matchMethod: 'code', + matchedCode: codeMatch.matchedCode, + }; + return { name: condName, category: codeMatch.category, source }; + } + + // Text-based match + const category = matchTextToCategory(condName); + const source: PrefillSource = { + type: 'clinical_record', + displayName: condName, + matchMethod: 'text', + }; + return { name: condName, category, source }; +} + +export function mapConditions(records: ClinicalCondRecord[]): MappedCondition[] { + return records.map((record) => { + const normalized = parseConditionRecord( + record.fhirResource as Record | undefined, + record.displayName, + ); + return mapSingleCondition(normalized, record.displayName); + }); +} + +export function groupByCategory(conditions: MappedCondition[]): { + diabetes: MappedCondition[]; + hypertension: MappedCondition[]; + bph: MappedCondition[]; + other: MappedCondition[]; +} { + const groups = { + diabetes: [] as MappedCondition[], + hypertension: [] as MappedCondition[], + bph: [] as MappedCondition[], + other: [] as MappedCondition[], + }; + + for (const cond of conditions) { + groups[cond.category].push(cond); + } + + return groups; +} diff --git a/homeflow/lib/services/fhir/fhir-parser.ts b/homeflow/lib/services/fhir/fhir-parser.ts new file mode 100644 index 0000000..3bd2170 --- /dev/null +++ b/homeflow/lib/services/fhir/fhir-parser.ts @@ -0,0 +1,322 @@ +/** + * FHIR Parser + * + * Parses raw FHIR JSON from Apple Health clinical records into + * normalized resource types. Handles both DSTU2 and R4 formats, + * as well as Bundle vs single-resource payloads. + */ + +import type { + NormalizedMedication, + NormalizedObservation, + NormalizedCondition, + NormalizedProcedure, + NormalizedResource, +} from './types'; + +type FhirJson = Record; + +// ── Coding helpers ────────────────────────────────────────────────── + +interface FhirCoding { + system?: string; + code?: string; + display?: string; +} + +function extractCodings(codeableConcept: unknown): FhirCoding[] { + if (!codeableConcept || typeof codeableConcept !== 'object') return []; + const cc = codeableConcept as Record; + + const codings: FhirCoding[] = []; + if (Array.isArray(cc.coding)) { + for (const c of cc.coding) { + if (c && typeof c === 'object') { + codings.push({ + system: typeof c.system === 'string' ? c.system : undefined, + code: typeof c.code === 'string' ? c.code : undefined, + display: typeof c.display === 'string' ? c.display : undefined, + }); + } + } + } + + // Fallback: text field + if (codings.length === 0 && typeof cc.text === 'string') { + codings.push({ display: cc.text }); + } + + return codings; +} + +function primaryCoding(codeableConcept: unknown): FhirCoding | undefined { + const codings = extractCodings(codeableConcept); + return codings[0]; +} + +function getDisplayName(codeableConcept: unknown): string { + const cc = codeableConcept as Record | undefined; + if (!cc) return ''; + + // Prefer text field + if (typeof cc.text === 'string') return cc.text; + + // Fall back to first coding display + const coding = primaryCoding(cc); + return coding?.display ?? ''; +} + +function safeString(val: unknown): string | undefined { + return typeof val === 'string' ? val : undefined; +} + +// ── Extract date from various FHIR date fields ───────────────────── + +function extractDate(resource: FhirJson, ...fields: string[]): string | undefined { + for (const field of fields) { + const val = resource[field]; + if (typeof val === 'string') return val; + // Handle Period objects (effectivePeriod, performedPeriod) + if (val && typeof val === 'object' && 'start' in (val as object)) { + const period = val as Record; + if (typeof period.start === 'string') return period.start; + } + } + return undefined; +} + +// ── Extract quantity value ────────────────────────────────────────── + +function extractQuantityValue(resource: FhirJson): { value?: number; unit?: string } { + // valueQuantity + const vq = resource.valueQuantity as Record | undefined; + if (vq && typeof vq.value === 'number') { + return { value: vq.value, unit: safeString(vq.unit) }; + } + + // valueString → try parsing number + if (typeof resource.valueString === 'string') { + const parsed = parseFloat(resource.valueString); + if (!isNaN(parsed)) return { value: parsed }; + } + + return {}; +} + +// ── Extract reference range ───────────────────────────────────────── + +function extractReferenceRange(resource: FhirJson): string | undefined { + const ranges = resource.referenceRange; + if (!Array.isArray(ranges) || ranges.length === 0) return undefined; + + const range = ranges[0] as Record; + if (typeof range.text === 'string') return range.text; + + const low = range.low as Record | undefined; + const high = range.high as Record | undefined; + if (low?.value !== undefined || high?.value !== undefined) { + const lowVal = typeof low?.value === 'number' ? low.value : '?'; + const highVal = typeof high?.value === 'number' ? high.value : '?'; + const unit = safeString(low?.unit) ?? safeString(high?.unit) ?? ''; + return `${lowVal}-${highVal} ${unit}`.trim(); + } + + return undefined; +} + +// ── Resource Parsers ──────────────────────────────────────────────── + +function parseMedication(resource: FhirJson): NormalizedMedication { + const rt = resource.resourceType as string; + + // R4: MedicationRequest, DSTU2: MedicationOrder + let name = ''; + const medicationCC = resource.medicationCodeableConcept; + if (medicationCC) { + name = getDisplayName(medicationCC); + } else if (resource.medicationReference && typeof resource.medicationReference === 'object') { + const ref = resource.medicationReference as Record; + name = safeString(ref.display) ?? ''; + } + + // MedicationStatement uses medication[x] too + if (!name && resource.medication) { + name = getDisplayName(resource.medication); + } + + return { + resourceType: rt as NormalizedMedication['resourceType'], + name, + code: primaryCoding(resource.medicationCodeableConcept ?? resource.medication), + status: safeString(resource.status), + dateWritten: extractDate(resource, 'dateWritten', 'authoredOn', 'dateAsserted'), + }; +} + +function parseObservation(resource: FhirJson): NormalizedObservation { + const { value, unit } = extractQuantityValue(resource); + + return { + resourceType: 'Observation', + code: primaryCoding(resource.code), + value, + unit, + valueString: typeof resource.valueString === 'string' ? resource.valueString : undefined, + effectiveDate: extractDate(resource, 'effectiveDateTime', 'effectivePeriod', 'issued'), + status: safeString(resource.status), + referenceRange: extractReferenceRange(resource), + }; +} + +function parseCondition(resource: FhirJson): NormalizedCondition { + return { + resourceType: 'Condition', + name: getDisplayName(resource.code), + code: primaryCoding(resource.code), + clinicalStatus: (() => { + // R4: clinicalStatus is a CodeableConcept + const cs = resource.clinicalStatus; + if (typeof cs === 'string') return cs; + if (cs && typeof cs === 'object') return getDisplayName(cs) || undefined; + return undefined; + })(), + onsetDate: extractDate(resource, 'onsetDateTime', 'onsetPeriod', 'recordedDate'), + }; +} + +function parseProcedure(resource: FhirJson): NormalizedProcedure { + return { + resourceType: 'Procedure', + name: getDisplayName(resource.code), + code: primaryCoding(resource.code), + status: safeString(resource.status), + performedDate: extractDate(resource, 'performedDateTime', 'performedPeriod'), + }; +} + +// ── Bundle handling ───────────────────────────────────────────────── + +function extractResourcesFromBundle(bundle: FhirJson): FhirJson[] { + const entry = bundle.entry; + if (!Array.isArray(entry)) return []; + + return entry + .map((e: Record) => e.resource as FhirJson) + .filter((r): r is FhirJson => r != null && typeof r.resourceType === 'string'); +} + +// ── Public API ────────────────────────────────────────────────────── + +export function parseResource(fhirJson: FhirJson | undefined | null): NormalizedResource | null { + if (!fhirJson || typeof fhirJson.resourceType !== 'string') return null; + + const rt = fhirJson.resourceType as string; + + switch (rt) { + case 'MedicationOrder': + case 'MedicationRequest': + case 'MedicationStatement': + return parseMedication(fhirJson); + + case 'Observation': + case 'DiagnosticReport': + return parseObservation(fhirJson); + + case 'Condition': + return parseCondition(fhirJson); + + case 'Procedure': + return parseProcedure(fhirJson); + + default: + return null; + } +} + +export function parseFhirPayload(fhirJson: FhirJson | undefined | null): NormalizedResource[] { + if (!fhirJson) return []; + + // Handle Bundle + if (fhirJson.resourceType === 'Bundle') { + const resources = extractResourcesFromBundle(fhirJson); + return resources.map(parseResource).filter((r): r is NormalizedResource => r !== null); + } + + // Single resource + const parsed = parseResource(fhirJson); + return parsed ? [parsed] : []; +} + +export function parseMedicationRecord( + fhirJson: FhirJson | undefined | null, + displayName: string, +): NormalizedMedication { + if (fhirJson) { + const parsed = parseResource(fhirJson); + if (parsed && 'name' in parsed && (parsed as NormalizedMedication).resourceType) { + const med = parsed as NormalizedMedication; + if (!med.name && displayName) med.name = displayName; + return med; + } + } + + // Fallback: use display name only + return { + resourceType: 'MedicationOrder', + name: displayName, + }; +} + +export function parseObservationRecord( + fhirJson: FhirJson | undefined | null, + displayName: string, +): NormalizedObservation { + if (fhirJson) { + const resources = parseFhirPayload(fhirJson); + const obs = resources.find((r) => r.resourceType === 'Observation') as NormalizedObservation | undefined; + if (obs) return obs; + } + + return { + resourceType: 'Observation', + code: { display: displayName }, + }; +} + +export function parseConditionRecord( + fhirJson: FhirJson | undefined | null, + displayName: string, +): NormalizedCondition { + if (fhirJson) { + const parsed = parseResource(fhirJson); + if (parsed?.resourceType === 'Condition') { + const cond = parsed as NormalizedCondition; + if (!cond.name && displayName) cond.name = displayName; + return cond; + } + } + + return { + resourceType: 'Condition', + name: displayName, + }; +} + +export function parseProcedureRecord( + fhirJson: FhirJson | undefined | null, + displayName: string, +): NormalizedProcedure { + if (fhirJson) { + const parsed = parseResource(fhirJson); + if (parsed?.resourceType === 'Procedure') { + const proc = parsed as NormalizedProcedure; + if (!proc.name && displayName) proc.name = displayName; + return proc; + } + } + + return { + resourceType: 'Procedure', + name: displayName, + }; +} diff --git a/homeflow/lib/services/fhir/index.ts b/homeflow/lib/services/fhir/index.ts new file mode 100644 index 0000000..fd71ed9 --- /dev/null +++ b/homeflow/lib/services/fhir/index.ts @@ -0,0 +1,50 @@ +/** + * FHIR Normalization Service + * + * Parses Apple Health clinical records into structured medical history data, + * classifies medications, maps conditions, and builds prefill data + * for the medical history chatbot. + */ + +// Types +export type { + PrefillEntry, + PrefillSource, + Confidence, + ClassifiedMedication, + BPHDrugClass, + LabValue, + MappedCondition, + KnownCondition, + MappedProcedure, + HealthKitDemographics, + MedicalHistoryPrefill, + ClinicalRecordsInput, + NormalizedMedication, + NormalizedObservation, + NormalizedCondition, + NormalizedProcedure, + NormalizedResource, +} from './types'; + +export { emptyEntry } from './types'; + +// Parser +export { parseResource, parseFhirPayload } from './fhir-parser'; + +// Classifiers / Mappers +export { classifyMedications, groupByDrugClass } from './medication-classifier'; +export { extractPSA, extractHbA1c, extractUrinalysis } from './lab-extractor'; +export { mapConditions, groupByCategory } from './condition-mapper'; +export { mapProcedures, separateProcedures } from './procedure-mapper'; + +// Orchestrator +export { + buildMedicalHistoryPrefill, + isFullyPrefilled, + getMissingFields, + getKnownFieldsSummary, +} from './prefill-builder'; + +// Prompt generation +export { buildModifiedSystemPrompt } from './prompt-modifier'; diff --git a/homeflow/lib/services/fhir/lab-extractor.ts b/homeflow/lib/services/fhir/lab-extractor.ts new file mode 100644 index 0000000..3c737d2 --- /dev/null +++ b/homeflow/lib/services/fhir/lab-extractor.ts @@ -0,0 +1,125 @@ +/** + * Lab Value Extractor + * + * Extracts PSA, HbA1c, and urinalysis results from FHIR Observation + * resources. Uses LOINC code matching first, then text-based fallback. + */ + +import type { PrefillEntry, PrefillSource, LabValue, NormalizedObservation } from './types'; +import { emptyEntry } from './types'; +import { LOINC, LAB_TEXT_PATTERNS } from './codes'; +import { parseObservationRecord } from './fhir-parser'; + +type ClinicalLabRecord = { + displayName: string; + fhirResource?: Record; +}; + +function matchesCode(obs: NormalizedObservation, targetCode: string): boolean { + if (!obs.code?.code) return false; + return obs.code.code === targetCode; +} + +function matchesTextPattern(name: string, patterns: readonly string[]): boolean { + const lower = name.toLowerCase(); + return patterns.some((p) => lower.includes(p)); +} + +function observationToLabValue(obs: NormalizedObservation): LabValue | null { + if (obs.value == null) return null; + return { + value: obs.value, + unit: obs.unit ?? '', + date: obs.effectiveDate ?? '', + referenceRange: obs.referenceRange, + }; +} + +function buildEntry( + obs: NormalizedObservation, + displayName: string, + matchMethod: 'code' | 'text', + matchedCode?: string, +): PrefillEntry { + const labValue = observationToLabValue(obs); + if (!labValue) return emptyEntry(); + + const source: PrefillSource = { + type: 'clinical_record', + displayName, + matchMethod, + matchedCode, + }; + + return { + value: labValue, + confidence: matchMethod === 'code' ? 'high' : 'medium', + sources: [source], + }; +} + +export function extractPSA(records: ClinicalLabRecord[]): PrefillEntry { + // Pass 1: LOINC code match + for (const record of records) { + const obs = parseObservationRecord(record.fhirResource, record.displayName); + if (matchesCode(obs, LOINC.PSA)) { + const entry = buildEntry(obs, record.displayName, 'code', `LOINC|${LOINC.PSA}`); + if (entry.value) return entry; + } + } + + // Pass 2: text match + for (const record of records) { + if (matchesTextPattern(record.displayName, LAB_TEXT_PATTERNS.psa)) { + const obs = parseObservationRecord(record.fhirResource, record.displayName); + const entry = buildEntry(obs, record.displayName, 'text'); + if (entry.value) return entry; + } + } + + return emptyEntry(); +} + +export function extractHbA1c(records: ClinicalLabRecord[]): PrefillEntry { + // Pass 1: LOINC code match + for (const record of records) { + const obs = parseObservationRecord(record.fhirResource, record.displayName); + if (matchesCode(obs, LOINC.HBA1C)) { + const entry = buildEntry(obs, record.displayName, 'code', `LOINC|${LOINC.HBA1C}`); + if (entry.value) return entry; + } + } + + // Pass 2: text match + for (const record of records) { + if (matchesTextPattern(record.displayName, LAB_TEXT_PATTERNS.hba1c)) { + const obs = parseObservationRecord(record.fhirResource, record.displayName); + const entry = buildEntry(obs, record.displayName, 'text'); + if (entry.value) return entry; + } + } + + return emptyEntry(); +} + +export function extractUrinalysis(records: ClinicalLabRecord[]): PrefillEntry { + // Pass 1: LOINC code match + for (const record of records) { + const obs = parseObservationRecord(record.fhirResource, record.displayName); + if (matchesCode(obs, LOINC.URINALYSIS_PANEL)) { + const entry = buildEntry(obs, record.displayName, 'code', `LOINC|${LOINC.URINALYSIS_PANEL}`); + if (entry.value) return entry; + } + } + + // Pass 2: text match + for (const record of records) { + if (matchesTextPattern(record.displayName, LAB_TEXT_PATTERNS.urinalysis)) { + const obs = parseObservationRecord(record.fhirResource, record.displayName); + const entry = buildEntry(obs, record.displayName, 'text'); + if (entry.value) return entry; + } + } + + return emptyEntry(); +} diff --git a/homeflow/lib/services/fhir/medication-classifier.ts b/homeflow/lib/services/fhir/medication-classifier.ts new file mode 100644 index 0000000..d085872 --- /dev/null +++ b/homeflow/lib/services/fhir/medication-classifier.ts @@ -0,0 +1,138 @@ +/** + * Medication Classifier + * + * Classifies medications from clinical records into BPH drug classes. + * Uses code-based matching (RxNorm) first, then text-based fallback. + */ + +import type { ClassifiedMedication, PrefillSource, NormalizedMedication } from './types'; +import { BPH_DRUGS, DRUG_NAME_MAP } from './codes'; +import { parseMedicationRecord } from './fhir-parser'; + +type ClinicalMedRecord = { + displayName: string; + fhirResource?: Record; +}; + +function matchByText(name: string): { drug: (typeof BPH_DRUGS)[number]; matchedName: string } | null { + const lower = name.toLowerCase(); + for (const drug of BPH_DRUGS) { + if (lower.includes(drug.generic)) { + return { drug, matchedName: drug.generic }; + } + for (const brand of drug.brands) { + if (lower.includes(brand)) { + return { drug, matchedName: brand }; + } + } + } + return null; +} + +function classifySingleMedication( + normalized: NormalizedMedication, + displayName: string, +): ClassifiedMedication { + const medName = normalized.name || displayName; + + // Try code-based match first (RxNorm) + if (normalized.code?.code) { + const codeLower = (normalized.code.display ?? '').toLowerCase(); + const codeMatch = matchByText(codeLower); + if (codeMatch) { + const source: PrefillSource = { + type: 'clinical_record', + displayName: medName, + matchMethod: 'code', + matchedCode: `${normalized.code.system ?? 'unknown'}|${normalized.code.code}`, + }; + return { + name: medName, + genericName: codeMatch.drug.generic, + drugClass: codeMatch.drug.class, + source, + }; + } + } + + // Text-based match on medication name + const textMatch = matchByText(medName); + if (textMatch) { + const source: PrefillSource = { + type: 'clinical_record', + displayName: medName, + matchMethod: 'text', + }; + return { + name: medName, + genericName: textMatch.drug.generic, + drugClass: textMatch.drug.class, + source, + }; + } + + // No match - unrelated medication + const source: PrefillSource = { + type: 'clinical_record', + displayName: medName, + matchMethod: 'text', + }; + return { + name: medName, + drugClass: 'unrelated', + source, + }; +} + +export function classifyMedications(records: ClinicalMedRecord[]): ClassifiedMedication[] { + return records.map((record) => { + const normalized = parseMedicationRecord( + record.fhirResource as Record | undefined, + record.displayName, + ); + return classifySingleMedication(normalized, record.displayName); + }); +} + +export function groupByDrugClass(medications: ClassifiedMedication[]): { + alphaBlockers: ClassifiedMedication[]; + fiveARIs: ClassifiedMedication[]; + anticholinergics: ClassifiedMedication[]; + beta3Agonists: ClassifiedMedication[]; + otherBPH: ClassifiedMedication[]; + unrelated: ClassifiedMedication[]; +} { + const groups = { + alphaBlockers: [] as ClassifiedMedication[], + fiveARIs: [] as ClassifiedMedication[], + anticholinergics: [] as ClassifiedMedication[], + beta3Agonists: [] as ClassifiedMedication[], + otherBPH: [] as ClassifiedMedication[], + unrelated: [] as ClassifiedMedication[], + }; + + for (const med of medications) { + switch (med.drugClass) { + case 'alpha_blocker': + groups.alphaBlockers.push(med); + break; + case 'five_ari': + groups.fiveARIs.push(med); + break; + case 'anticholinergic': + groups.anticholinergics.push(med); + break; + case 'beta3_agonist': + groups.beta3Agonists.push(med); + break; + case 'other_bph': + groups.otherBPH.push(med); + break; + default: + groups.unrelated.push(med); + break; + } + } + + return groups; +} diff --git a/homeflow/lib/services/fhir/prefill-builder.ts b/homeflow/lib/services/fhir/prefill-builder.ts new file mode 100644 index 0000000..15489fe --- /dev/null +++ b/homeflow/lib/services/fhir/prefill-builder.ts @@ -0,0 +1,322 @@ +/** + * Prefill Builder + * + * Main orchestrator: takes clinical records + HealthKit demographics + * and builds a complete MedicalHistoryPrefill with confidence scores. + */ + +import type { + MedicalHistoryPrefill, + PrefillEntry, + ClinicalRecordsInput, + HealthKitDemographics, + ClassifiedMedication, + MappedCondition, + MappedProcedure, + LabValue, + PrefillSource, +} from './types'; +import { emptyEntry } from './types'; +import { classifyMedications, groupByDrugClass } from './medication-classifier'; +import { extractPSA, extractHbA1c, extractUrinalysis } from './lab-extractor'; +import { mapConditions, groupByCategory } from './condition-mapper'; +import { mapProcedures, separateProcedures } from './procedure-mapper'; + +// ── Demographics from HealthKit ───────────────────────────────────── + +function buildDemographics(demographics: HealthKitDemographics | null) { + const age: PrefillEntry = demographics?.age != null + ? { + value: demographics.age, + confidence: 'high', + sources: [{ + type: 'healthkit', + displayName: `Age: ${demographics.age}`, + matchMethod: 'direct_api', + }], + } + : emptyEntry(); + + const biologicalSex: PrefillEntry = demographics?.biologicalSex + ? { + value: demographics.biologicalSex, + confidence: 'high', + sources: [{ + type: 'healthkit', + displayName: `Sex: ${demographics.biologicalSex}`, + matchMethod: 'direct_api', + }], + } + : emptyEntry(); + + return { + age, + biologicalSex, + fullName: emptyEntry(), + ethnicity: emptyEntry(), + race: emptyEntry(), + }; +} + +// ── Medication entries ────────────────────────────────────────────── + +function buildMedicationEntry( + meds: ClassifiedMedication[], +): PrefillEntry { + if (meds.length === 0) return emptyEntry(); + + const hasCodeMatch = meds.some((m) => m.source.matchMethod === 'code'); + return { + value: meds, + confidence: hasCodeMatch ? 'high' : 'medium', + sources: meds.map((m) => m.source), + }; +} + +// ── Condition entries ─────────────────────────────────────────────── + +function buildConditionEntry( + conditions: MappedCondition[], +): PrefillEntry { + if (conditions.length === 0) return emptyEntry(); + + const hasCodeMatch = conditions.some((c) => c.source.matchMethod === 'code'); + return { + value: conditions, + confidence: hasCodeMatch ? 'high' : 'medium', + sources: conditions.map((c) => c.source), + }; +} + +// ── Procedure entries ─────────────────────────────────────────────── + +function buildProcedureEntry( + procedures: MappedProcedure[], +): PrefillEntry { + if (procedures.length === 0) return emptyEntry(); + + const hasCodeMatch = procedures.some((p) => p.source.matchMethod === 'code'); + return { + value: procedures, + confidence: hasCodeMatch ? 'high' : 'medium', + sources: procedures.map((p) => p.source), + }; +} + +// ── Public API ────────────────────────────────────────────────────── + +export function buildMedicalHistoryPrefill( + clinicalRecords: ClinicalRecordsInput | null, + demographics: HealthKitDemographics | null, +): MedicalHistoryPrefill { + // Classify medications + const allMeds = clinicalRecords + ? classifyMedications(clinicalRecords.medications) + : []; + const medGroups = groupByDrugClass(allMeds); + + // Map conditions + const allConditions = clinicalRecords + ? mapConditions(clinicalRecords.conditions) + : []; + const condGroups = groupByCategory(allConditions); + + // Map procedures + const allProcedures = clinicalRecords + ? mapProcedures(clinicalRecords.procedures) + : []; + const procGroups = separateProcedures(allProcedures); + + // Extract labs + const labRecords = clinicalRecords?.labResults ?? []; + + return { + demographics: buildDemographics(demographics), + + medications: { + alphaBlockers: buildMedicationEntry(medGroups.alphaBlockers), + fiveARIs: buildMedicationEntry(medGroups.fiveARIs), + anticholinergics: buildMedicationEntry(medGroups.anticholinergics), + beta3Agonists: buildMedicationEntry(medGroups.beta3Agonists), + otherBPH: buildMedicationEntry(medGroups.otherBPH), + }, + + surgicalHistory: { + bphProcedures: buildProcedureEntry(procGroups.bphProcedures), + otherProcedures: buildProcedureEntry(procGroups.otherProcedures), + }, + + labs: { + psa: extractPSA(labRecords), + hba1c: extractHbA1c(labRecords), + urinalysis: extractUrinalysis(labRecords), + }, + + conditions: { + diabetes: buildConditionEntry(condGroups.diabetes), + hypertension: buildConditionEntry(condGroups.hypertension), + bph: buildConditionEntry(condGroups.bph), + other: buildConditionEntry(condGroups.other), + }, + + clinicalMeasurements: { + pvr: emptyEntry(), + uroflowQmax: emptyEntry(), + mobility: emptyEntry(), + }, + + upcomingSurgery: { + date: emptyEntry(), + type: emptyEntry(), + }, + }; +} + +/** + * Check if all required fields are filled. + * "Fully prefilled" means: demographics (age, sex), medications reviewed, + * conditions reviewed, surgical history reviewed, and labs reviewed. + * + * Fields that are always `none` (fullName, ethnicity, race, upcoming surgery) + * are excluded from this check since they must always be asked. + */ +export function isFullyPrefilled(prefill: MedicalHistoryPrefill): boolean { + // Demographics: age and biologicalSex must be known + if (prefill.demographics.age.confidence === 'none') return false; + if (prefill.demographics.biologicalSex.confidence === 'none') return false; + + // We always need to ask: fullName, ethnicity, race, upcoming surgery, clinical measurements + // So "fully prefilled" is never truly possible when those fields matter. + // Instead, we check if the *medical data* sections are covered. + + // At least one medication category must have been checked (records exist) + const hasMedData = [ + prefill.medications.alphaBlockers, + prefill.medications.fiveARIs, + prefill.medications.anticholinergics, + prefill.medications.beta3Agonists, + prefill.medications.otherBPH, + ].some((entry) => entry.confidence !== 'none'); + + // Conditions must have been checked + const hasConditionData = [ + prefill.conditions.diabetes, + prefill.conditions.hypertension, + prefill.conditions.bph, + prefill.conditions.other, + ].some((entry) => entry.confidence !== 'none'); + + return hasMedData && hasConditionData; +} + +/** + * Get a list of fields that still need to be asked about. + */ +export function getMissingFields(prefill: MedicalHistoryPrefill): string[] { + const missing: string[] = []; + + // Always need to ask these + missing.push('fullName', 'ethnicity', 'race'); + + // Demographics + if (prefill.demographics.age.confidence === 'none') missing.push('age'); + if (prefill.demographics.biologicalSex.confidence === 'none') missing.push('biologicalSex'); + + // Medications - if none found, we need to ask + const noMedData = [ + prefill.medications.alphaBlockers, + prefill.medications.fiveARIs, + prefill.medications.anticholinergics, + prefill.medications.beta3Agonists, + prefill.medications.otherBPH, + ].every((entry) => entry.confidence === 'none'); + if (noMedData) missing.push('medications'); + + // Surgical history + if (prefill.surgicalHistory.bphProcedures.confidence === 'none' && + prefill.surgicalHistory.otherProcedures.confidence === 'none') { + missing.push('surgicalHistory'); + } + + // Labs + if (prefill.labs.psa.confidence === 'none') missing.push('psa'); + if (prefill.labs.hba1c.confidence === 'none') missing.push('hba1c'); + if (prefill.labs.urinalysis.confidence === 'none') missing.push('urinalysis'); + + // Conditions + const noCondData = [ + prefill.conditions.diabetes, + prefill.conditions.hypertension, + prefill.conditions.bph, + prefill.conditions.other, + ].every((entry) => entry.confidence === 'none'); + if (noCondData) missing.push('conditions'); + + // Clinical measurements (always need to ask) + missing.push('clinicalMeasurements'); + + // Upcoming surgery (always need to ask) + missing.push('upcomingSurgery'); + + return missing; +} + +/** + * Get a human-readable summary of known fields. + */ +export function getKnownFieldsSummary(prefill: MedicalHistoryPrefill): string[] { + const known: string[] = []; + + if (prefill.demographics.age.value != null) { + known.push(`Age: ${prefill.demographics.age.value}`); + } + if (prefill.demographics.biologicalSex.value) { + known.push(`Biological sex: ${prefill.demographics.biologicalSex.value}`); + } + + // Medications + const medCategories = [ + { label: 'Alpha blockers', entry: prefill.medications.alphaBlockers }, + { label: '5-ARIs', entry: prefill.medications.fiveARIs }, + { label: 'Anticholinergics', entry: prefill.medications.anticholinergics }, + { label: 'Beta-3 agonists', entry: prefill.medications.beta3Agonists }, + { label: 'Other BPH meds', entry: prefill.medications.otherBPH }, + ]; + for (const { label, entry } of medCategories) { + if (entry.value && entry.value.length > 0) { + const names = entry.value.map((m) => m.name).join(', '); + known.push(`${label}: ${names}`); + } + } + + // Conditions + const condCategories = [ + { label: 'Diabetes', entry: prefill.conditions.diabetes }, + { label: 'Hypertension', entry: prefill.conditions.hypertension }, + { label: 'BPH', entry: prefill.conditions.bph }, + ]; + for (const { label, entry } of condCategories) { + if (entry.value && entry.value.length > 0) { + known.push(`${label}: Yes (from health records)`); + } + } + + // Labs + if (prefill.labs.psa.value) { + known.push(`PSA: ${prefill.labs.psa.value.value} ${prefill.labs.psa.value.unit}`); + } + if (prefill.labs.hba1c.value) { + known.push(`HbA1c: ${prefill.labs.hba1c.value.value}${prefill.labs.hba1c.value.unit}`); + } + + // Procedures + if (prefill.surgicalHistory.bphProcedures.value?.length) { + const names = prefill.surgicalHistory.bphProcedures.value.map((p) => p.name).join(', '); + known.push(`BPH procedures: ${names}`); + } + if (prefill.surgicalHistory.otherProcedures.value?.length) { + known.push(`Other surgeries: ${prefill.surgicalHistory.otherProcedures.value.length} found`); + } + + return known; +} diff --git a/homeflow/lib/services/fhir/procedure-mapper.ts b/homeflow/lib/services/fhir/procedure-mapper.ts new file mode 100644 index 0000000..f98b113 --- /dev/null +++ b/homeflow/lib/services/fhir/procedure-mapper.ts @@ -0,0 +1,66 @@ +/** + * Procedure Mapper + * + * Separates BPH-related procedures from general surgical history + * using keyword matching on procedure names and codes. + */ + +import type { MappedProcedure, PrefillSource } from './types'; +import { BPH_PROCEDURE_KEYWORDS } from './codes'; +import { parseProcedureRecord } from './fhir-parser'; + +type ClinicalProcRecord = { + displayName: string; + fhirResource?: Record; +}; + +function isBPHProcedure(name: string): boolean { + const lower = name.toLowerCase(); + return BPH_PROCEDURE_KEYWORDS.some((keyword) => lower.includes(keyword)); +} + +export function mapProcedures(records: ClinicalProcRecord[]): MappedProcedure[] { + return records.map((record) => { + const normalized = parseProcedureRecord( + record.fhirResource as Record | undefined, + record.displayName, + ); + + const procName = normalized.name || record.displayName; + const isBPH = isBPHProcedure(procName); + + const source: PrefillSource = { + type: 'clinical_record', + displayName: procName, + matchMethod: normalized.code?.code ? 'code' : 'text', + matchedCode: normalized.code?.code + ? `${normalized.code.system ?? 'unknown'}|${normalized.code.code}` + : undefined, + }; + + return { + name: procName, + date: normalized.performedDate, + isBPH, + source, + }; + }); +} + +export function separateProcedures(procedures: MappedProcedure[]): { + bphProcedures: MappedProcedure[]; + otherProcedures: MappedProcedure[]; +} { + const bphProcedures: MappedProcedure[] = []; + const otherProcedures: MappedProcedure[] = []; + + for (const proc of procedures) { + if (proc.isBPH) { + bphProcedures.push(proc); + } else { + otherProcedures.push(proc); + } + } + + return { bphProcedures, otherProcedures }; +} diff --git a/homeflow/lib/services/fhir/prompt-modifier.ts b/homeflow/lib/services/fhir/prompt-modifier.ts new file mode 100644 index 0000000..b8dca04 --- /dev/null +++ b/homeflow/lib/services/fhir/prompt-modifier.ts @@ -0,0 +1,82 @@ +/** + * Prompt Modifier + * + * Generates a modified system prompt for the medical history chatbot + * that tells it which fields are already known from health records, + * so it only asks about gaps. + */ + +import type { MedicalHistoryPrefill } from './types'; +import { getMissingFields, getKnownFieldsSummary } from './prefill-builder'; +import { STUDY_INFO } from '@/lib/constants'; + +/** + * Build a modified system prompt that incorporates known health record data. + * The chatbot will confirm high-confidence items briefly and focus on gaps. + */ +export function buildModifiedSystemPrompt( + prefill: MedicalHistoryPrefill, + baseStudyInfo?: { name: string; institution: string }, +): string { + const study = baseStudyInfo ?? STUDY_INFO; + const known = getKnownFieldsSummary(prefill); + const missing = getMissingFields(prefill); + + const knownSection = known.length > 0 + ? known.map((item) => `- ${item}`).join('\n') + : '(No health records data available)'; + + const missingList = missing.map((field) => { + switch (field) { + case 'fullName': return 'Full name (for study records)'; + case 'ethnicity': return 'Ethnicity (Hispanic/Latino or Not)'; + case 'race': return 'Race'; + case 'age': return 'Age / Date of Birth'; + case 'biologicalSex': return 'Biological Sex'; + case 'medications': return 'BPH/LUTS Medications (alpha blockers, 5-ARIs, anticholinergics, beta-3 agonists)'; + case 'surgicalHistory': return 'Surgical History (BPH and general)'; + case 'psa': return 'PSA level (most recent)'; + case 'hba1c': return 'HbA1c level'; + case 'urinalysis': return 'Urinalysis results'; + case 'conditions': return 'Medical conditions (diabetes, hypertension, etc.)'; + case 'clinicalMeasurements': return 'Clinical measurements (PVR, clinic uroflow, mobility)'; + case 'upcomingSurgery': return 'Upcoming surgery details (date and type)'; + default: return field; + } + }); + + return `You are a friendly research assistant collecting medical history for the ${study.name} study at ${study.institution}. The participant has already been confirmed eligible and has given informed consent. + +## Pre-filled Data from Health Records + +We already have the following information from the participant's Apple Health records. You do NOT need to ask about these, but you may briefly confirm them: + +${knownSection} + +## What You Still Need to Collect + +Focus your questions on these missing fields: +${missingList.map((item) => `- ${item}`).join('\n')} + +## Conversation Guidelines +- Be warm, conversational, and empathetic +- Start by briefly acknowledging what we already know from their health records +- Ask 2-3 related items at a time, don't overwhelm +- Group questions logically +- If they don't know a value (like PSA or HbA1c), that's OK - note "unknown" and continue +- NEVER give medical advice or interpret their values + +## Important Response Markers +When ALL medical history sections are complete: [HISTORY_COMPLETE] + +## Start the Conversation +"Thanks for completing the consent process! I can see some of your health information has already been pulled from your Apple Health records${known.length > 0 ? ' - I have your ' + summarizeKnownBriefly(known) : ''}. I just need to ask about a few more things to complete your medical history. + +Let's start with some basic demographics - could you tell me your full name?"`; +} + +function summarizeKnownBriefly(known: string[]): string { + if (known.length === 0) return ''; + if (known.length <= 2) return known.join(' and '); + return `${known.slice(0, 2).join(', ')}, and ${known.length - 2} more item${known.length - 2 > 1 ? 's' : ''}`; +} diff --git a/homeflow/lib/services/fhir/types.ts b/homeflow/lib/services/fhir/types.ts new file mode 100644 index 0000000..796a025 --- /dev/null +++ b/homeflow/lib/services/fhir/types.ts @@ -0,0 +1,181 @@ +/** + * FHIR Normalization Types + * + * Interfaces for medical history prefill data extracted from + * Apple Health clinical records and HealthKit demographics. + */ + +// ── Prefill Entry ─────────────────────────────────────────────────── + +export type Confidence = 'high' | 'medium' | 'low' | 'none'; + +export interface PrefillSource { + type: string; + displayName: string; + matchMethod: 'code' | 'text' | 'direct_api'; + matchedCode?: string; +} + +export interface PrefillEntry { + value: T | null; + confidence: Confidence; + sources: PrefillSource[]; +} + +/** Create an empty prefill entry (no data found) */ +export function emptyEntry(): PrefillEntry { + return { value: null, confidence: 'none', sources: [] }; +} + +// ── Medication Classification ─────────────────────────────────────── + +export type BPHDrugClass = + | 'alpha_blocker' + | 'five_ari' + | 'anticholinergic' + | 'beta3_agonist' + | 'other_bph'; + +export interface ClassifiedMedication { + name: string; + genericName?: string; + drugClass: BPHDrugClass | 'unrelated'; + source: PrefillSource; +} + +// ── Lab Values ────────────────────────────────────────────────────── + +export interface LabValue { + value: number; + unit: string; + date: string; + referenceRange?: string; +} + +// ── Condition ─────────────────────────────────────────────────────── + +export type KnownCondition = 'diabetes' | 'hypertension' | 'bph' | 'other'; + +export interface MappedCondition { + name: string; + category: KnownCondition; + source: PrefillSource; +} + +// ── Procedure ─────────────────────────────────────────────────────── + +export interface MappedProcedure { + name: string; + date?: string; + isBPH: boolean; + source: PrefillSource; +} + +// ── Demographics ──────────────────────────────────────────────────── + +export interface HealthKitDemographics { + age: number | null; + dateOfBirth: string | null; + biologicalSex: string | null; +} + +// ── Medical History Prefill (7 sections) ──────────────────────────── + +export interface MedicalHistoryPrefill { + demographics: { + age: PrefillEntry; + biologicalSex: PrefillEntry; + fullName: PrefillEntry; + ethnicity: PrefillEntry; + race: PrefillEntry; + }; + + medications: { + alphaBlockers: PrefillEntry; + fiveARIs: PrefillEntry; + anticholinergics: PrefillEntry; + beta3Agonists: PrefillEntry; + otherBPH: PrefillEntry; + }; + + surgicalHistory: { + bphProcedures: PrefillEntry; + otherProcedures: PrefillEntry; + }; + + labs: { + psa: PrefillEntry; + hba1c: PrefillEntry; + urinalysis: PrefillEntry; + }; + + conditions: { + diabetes: PrefillEntry; + hypertension: PrefillEntry; + bph: PrefillEntry; + other: PrefillEntry; + }; + + clinicalMeasurements: { + pvr: PrefillEntry; + uroflowQmax: PrefillEntry; + mobility: PrefillEntry; + }; + + upcomingSurgery: { + date: PrefillEntry; + type: PrefillEntry; + }; +} + +// ── Normalized FHIR Resource Types ────────────────────────────────── + +export interface NormalizedMedication { + resourceType: 'MedicationOrder' | 'MedicationRequest' | 'MedicationStatement'; + name: string; + code?: { system?: string; code?: string; display?: string }; + status?: string; + dateWritten?: string; +} + +export interface NormalizedObservation { + resourceType: 'Observation'; + code?: { system?: string; code?: string; display?: string }; + value?: number; + unit?: string; + valueString?: string; + effectiveDate?: string; + status?: string; + referenceRange?: string; +} + +export interface NormalizedCondition { + resourceType: 'Condition'; + name: string; + code?: { system?: string; code?: string; display?: string }; + clinicalStatus?: string; + onsetDate?: string; +} + +export interface NormalizedProcedure { + resourceType: 'Procedure'; + name: string; + code?: { system?: string; code?: string; display?: string }; + status?: string; + performedDate?: string; +} + +export type NormalizedResource = + | NormalizedMedication + | NormalizedObservation + | NormalizedCondition + | NormalizedProcedure; + +// ── Clinical Records Input ────────────────────────────────────────── + +export interface ClinicalRecordsInput { + medications: Array<{ displayName: string; fhirResource?: Record }>; + labResults: Array<{ displayName: string; fhirResource?: Record }>; + conditions: Array<{ displayName: string; fhirResource?: Record }>; + procedures: Array<{ displayName: string; fhirResource?: Record }>; +} diff --git a/homeflow/lib/services/health-summary/derive-insights.ts b/homeflow/lib/services/health-summary/derive-insights.ts new file mode 100644 index 0000000..8a31005 --- /dev/null +++ b/homeflow/lib/services/health-summary/derive-insights.ts @@ -0,0 +1,225 @@ +/** + * Insight Derivation + * + * Pure functions that transform raw HealthKit data into plain-language + * insights for the Daily Check-In screen. No React dependencies. + */ + +import type { SleepNight, DailyActivity, VitalsDay } from '@/lib/services/healthkit'; +import type { + SleepInsight, + ActivityInsight, + VitalsInsight, + VitalItem, + HealthSummaryDay, + InsightStatus, +} from './types'; + +function round1(n: number): number { + return Math.round(n * 10) / 10; +} + +// ── Sleep ───────────────────────────────────────────────────────────── + +export function deriveSleepInsight( + tonight: SleepNight, + recentNights: SleepNight[], +): SleepInsight { + const totalHours = round1(tonight.totalAsleepMinutes / 60); + + // 7-day baseline average (excluding tonight) + const validNights = recentNights.filter((n) => n.totalAsleepMinutes > 0); + const baselineHours = + validNights.length > 0 + ? round1( + validNights.reduce((sum, n) => sum + n.totalAsleepMinutes, 0) / + validNights.length / + 60, + ) + : totalHours; + + // Status: within 15% of baseline + const ratio = baselineHours > 0 ? totalHours / baselineHours : 1; + let status: InsightStatus; + if (ratio >= 0.85 && ratio <= 1.15) { + status = 'steady'; + } else if (ratio > 1.15) { + status = 'above-typical'; + } else { + status = 'below-typical'; + } + + // Bar fill: clamp to 0–1 relative to baseline (cap at 1.3x) + const barFill = baselineHours > 0 ? Math.min(totalHours / baselineHours, 1.3) / 1.3 : 0.5; + + let headline: string; + let supportingText: string; + + if (status === 'steady') { + headline = `You slept about ${totalHours} hours`; + supportingText = "That's close to your usual rest pattern"; + } else if (status === 'above-typical') { + headline = `A longer night \u2014 about ${totalHours} hours`; + supportingText = 'A bit more rest than your recent average'; + } else { + headline = `A shorter night \u2014 about ${totalHours} hours`; + supportingText = "A little less than your recent average \u2014 that's OK"; + } + + const stages = tonight.hasDetailedStages + ? { + deep: tonight.stages.deep, + core: tonight.stages.core, + rem: tonight.stages.rem, + awake: tonight.stages.awake, + } + : null; + + return { + headline, + supportingText, + status, + totalHours, + baselineHours, + barFill, + efficiency: tonight.sleepEfficiency, + stages, + }; +} + +// ── Activity ────────────────────────────────────────────────────────── + +export function deriveActivityInsight(today: DailyActivity): ActivityInsight { + const activeMinutes = today.exerciseMinutes + today.moveMinutes; + + let headline: string; + let supportingText: string; + let status: InsightStatus; + + if (activeMinutes >= 30) { + headline = 'An active day'; + supportingText = `About ${activeMinutes} minutes of movement \u2014 good energy spent`; + status = 'steady'; + } else if (activeMinutes >= 10) { + headline = 'Some movement today'; + supportingText = `${activeMinutes} minutes of movement so far`; + status = 'steady'; + } else { + headline = 'A quieter day'; + supportingText = "Your body may be resting today \u2014 that's part of the rhythm"; + status = 'below-typical'; + } + + return { + headline, + supportingText, + status, + activeMinutes, + steps: today.steps, + energyBurned: today.activeEnergyBurned, + distance: today.distanceWalkingRunning, + }; +} + +// ── Vitals ──────────────────────────────────────────────────────────── + +export function deriveVitalsInsight(today: VitalsDay): VitalsInsight { + const items: VitalItem[] = []; + + if (today.restingHeartRate != null) { + items.push({ + label: 'Resting heart rate', + value: `${today.restingHeartRate} bpm`, + status: 'steady', + }); + } + + if (today.hrv != null) { + items.push({ + label: 'Heart rate variability', + value: `${today.hrv} ms`, + status: 'steady', + }); + } + + if (today.respiratoryRate != null) { + items.push({ + label: 'Respiratory rate', + value: `${today.respiratoryRate} br/min`, + status: 'steady', + }); + } + + if (today.oxygenSaturation != null) { + items.push({ + label: 'Blood oxygen', + value: `${today.oxygenSaturation}%`, + status: 'steady', + }); + } + + const hasData = items.length > 0; + + return { + headline: hasData ? 'All vitals look steady' : 'No vitals recorded today', + status: hasData ? 'steady' : 'no-data', + items, + }; +} + +// ── Orchestrator ────────────────────────────────────────────────────── + +function formatDateLabel(dateStr: string): string { + const date = new Date(dateStr + 'T12:00:00'); + return date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + }); +} + +const NO_SLEEP: SleepInsight = { + headline: 'No sleep data yet', + supportingText: 'Sleep tracking will appear here when available', + status: 'no-data', + totalHours: 0, + baselineHours: 0, + barFill: 0, + efficiency: 0, + stages: null, +}; + +const NO_ACTIVITY: ActivityInsight = { + headline: 'No activity data yet', + supportingText: 'Activity tracking will appear here when available', + status: 'no-data', + activeMinutes: 0, + steps: 0, + energyBurned: 0, + distance: 0, +}; + +const NO_VITALS: VitalsInsight = { + headline: 'No vitals recorded yet', + supportingText: 'Vitals will appear here when available', + status: 'no-data', + items: [], +}; + +export function buildHealthSummaryDay( + todayDate: string, + sleep: SleepNight | null, + recentSleep: SleepNight[], + activity: DailyActivity | null, + vitals: VitalsDay | null, +): HealthSummaryDay { + return { + date: todayDate, + dateLabel: formatDateLabel(todayDate), + greeting: 'Your daily check-in', + sleep: sleep ? deriveSleepInsight(sleep, recentSleep) : NO_SLEEP, + activity: activity ? deriveActivityInsight(activity) : NO_ACTIVITY, + vitals: vitals ? deriveVitalsInsight(vitals) : NO_VITALS, + raw: { sleep, activity, vitals }, + }; +} diff --git a/homeflow/lib/services/health-summary/index.ts b/homeflow/lib/services/health-summary/index.ts new file mode 100644 index 0000000..c14f52f --- /dev/null +++ b/homeflow/lib/services/health-summary/index.ts @@ -0,0 +1,10 @@ +export { buildHealthSummaryDay, deriveSleepInsight, deriveActivityInsight, deriveVitalsInsight } from './derive-insights'; + +export type { + InsightStatus, + SleepInsight, + ActivityInsight, + VitalsInsight, + VitalItem, + HealthSummaryDay, +} from './types'; diff --git a/homeflow/lib/services/health-summary/types.ts b/homeflow/lib/services/health-summary/types.ts new file mode 100644 index 0000000..2b53c8b --- /dev/null +++ b/homeflow/lib/services/health-summary/types.ts @@ -0,0 +1,58 @@ +/** + * Health Summary View-Model Types + * + * Derived types for presenting HealthKit data in the Daily Check-In screen. + * These sit between raw HealthKit types and UI components. + */ + +import type { SleepNight, DailyActivity, VitalsDay } from '@/lib/services/healthkit'; + +export type InsightStatus = 'steady' | 'above-typical' | 'below-typical' | 'no-data'; + +export interface SleepInsight { + headline: string; + supportingText: string; + status: InsightStatus; + totalHours: number; + baselineHours: number; + barFill: number; + efficiency: number; + stages: { deep: number; core: number; rem: number; awake: number } | null; +} + +export interface ActivityInsight { + headline: string; + supportingText: string; + status: InsightStatus; + activeMinutes: number; + steps: number; + energyBurned: number; + distance: number; +} + +export interface VitalItem { + label: string; + value: string; + status: InsightStatus; +} + +export interface VitalsInsight { + headline: string; + supportingText?: string; + status: InsightStatus; + items: VitalItem[]; +} + +export interface HealthSummaryDay { + date: string; + dateLabel: string; + greeting: string; + sleep: SleepInsight | null; + activity: ActivityInsight | null; + vitals: VitalsInsight | null; + raw: { + sleep: SleepNight | null; + activity: DailyActivity | null; + vitals: VitalsDay | null; + }; +} diff --git a/homeflow/lib/services/healthkit/ClinicalRecordsClient.ts b/homeflow/lib/services/healthkit/ClinicalRecordsClient.ts new file mode 100644 index 0000000..a5f0e0a --- /dev/null +++ b/homeflow/lib/services/healthkit/ClinicalRecordsClient.ts @@ -0,0 +1,121 @@ +/** + * Clinical Records Client + * + * App-level convenience functions for Apple Health Clinical Records. + * Wraps the expo-clinical-records module with typed helpers for + * medications, lab results, conditions, and procedures. + * + * iOS only — all functions return empty/default data on non-iOS platforms. + */ + +import { + isClinicalRecordsAvailable, + requestClinicalRecordsAuthorization, + getClinicalRecords, + ClinicalRecordType, +} from '@/modules/expo-clinical-records/src'; +import type { + ClinicalRecord, + ClinicalRecordsAuthResult, +} from '@/modules/expo-clinical-records/src'; +import type { DateRange, HealthPermissionResult } from './types'; + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Check if clinical records (FHIR) are available on this device. + * Returns false on non-iOS, simulators, or devices without Health Records support. + */ +export function areClinicalRecordsAvailable(): boolean { + return isClinicalRecordsAvailable(); +} + +/** + * Request permission to read clinical records. + * Prompts the standard HealthKit authorization sheet for clinical data. + */ +export async function requestClinicalPermissions(): Promise { + const result: ClinicalRecordsAuthResult = await requestClinicalRecordsAuthorization(); + return { + success: result.success, + note: result.note, + }; +} + +/** + * Get medication records from Apple Health. + */ +export async function getClinicalMedications( + range?: DateRange, +): Promise { + return getClinicalRecords( + ClinicalRecordType.MedicationRecord, + buildOptions(range), + ); +} + +/** + * Get lab result records from Apple Health. + */ +export async function getClinicalLabResults( + range?: DateRange, +): Promise { + return getClinicalRecords( + ClinicalRecordType.LabResultRecord, + buildOptions(range), + ); +} + +/** + * Get condition records from Apple Health. + */ +export async function getClinicalConditions( + range?: DateRange, +): Promise { + return getClinicalRecords( + ClinicalRecordType.ConditionRecord, + buildOptions(range), + ); +} + +/** + * Get procedure records from Apple Health. + */ +export async function getClinicalProcedures( + range?: DateRange, +): Promise { + return getClinicalRecords( + ClinicalRecordType.ProcedureRecord, + buildOptions(range), + ); +} + +/** + * Fetch all supported clinical record types at once. + * Runs queries in parallel for efficiency. + */ +export async function getAllClinicalRecords(range?: DateRange): Promise<{ + medications: ClinicalRecord[]; + labResults: ClinicalRecord[]; + conditions: ClinicalRecord[]; + procedures: ClinicalRecord[]; +}> { + const [medications, labResults, conditions, procedures] = await Promise.all([ + getClinicalMedications(range), + getClinicalLabResults(range), + getClinicalConditions(range), + getClinicalProcedures(range), + ]); + + return { medications, labResults, conditions, procedures }; +} + +// ── Helpers ───────────────────────────────────────────────────────── + +function buildOptions(range?: DateRange) { + if (!range) return undefined; + return { + startDate: range.startDate.toISOString(), + endDate: range.endDate.toISOString(), + }; +} diff --git a/homeflow/lib/services/healthkit/HealthKitClient.ts b/homeflow/lib/services/healthkit/HealthKitClient.ts new file mode 100644 index 0000000..a14f2bf --- /dev/null +++ b/homeflow/lib/services/healthkit/HealthKitClient.ts @@ -0,0 +1,420 @@ +/** + * HealthKit Client + * + * Clean abstraction over @kingstinct/react-native-healthkit. + * Exposes functions for permissions, activity, sleep, and vitals queries. + * iOS only — all functions return empty/default data on non-iOS platforms. + */ + +import { Platform } from 'react-native'; +import { + requestAuthorization, + queryQuantitySamples, + queryCategorySamples, + isHealthDataAvailable, + getBiologicalSex as hkGetBiologicalSex, + getDateOfBirth as hkGetDateOfBirth, + BiologicalSex, +} from '@kingstinct/react-native-healthkit'; +import type { QuantitySample } from '@kingstinct/react-native-healthkit'; +import type { HealthKitDemographics } from '../fhir/types'; + +import { + formatDateKey, + getDateKeysInRange, + bucketSamplesByDay, + sumSamples, + statsSamples, + mapCategorySampleToSleepSample, + getSleepNightDate, + estimateSedentaryMinutes, +} from './mappers'; + +import { + SleepStage, + type DateRange, + type DailyActivity, + type SleepNight, + type VitalsDay, + type HealthPermissionResult, +} from './types'; + +// ── HK Type Identifiers ──────────────────────────────────────────── +// Using the full Apple string identifiers as required by the library. + +const HK = { + // Activity + stepCount: 'HKQuantityTypeIdentifierStepCount' as const, + activeEnergy: 'HKQuantityTypeIdentifierActiveEnergyBurned' as const, + exerciseTime: 'HKQuantityTypeIdentifierAppleExerciseTime' as const, + moveTime: 'HKQuantityTypeIdentifierAppleMoveTime' as const, + standTime: 'HKQuantityTypeIdentifierAppleStandTime' as const, + distance: 'HKQuantityTypeIdentifierDistanceWalkingRunning' as const, + + // Sleep (category type) + sleepAnalysis: 'HKCategoryTypeIdentifierSleepAnalysis' as const, + + // Vitals + heartRate: 'HKQuantityTypeIdentifierHeartRate' as const, + restingHeartRate: 'HKQuantityTypeIdentifierRestingHeartRate' as const, + hrv: 'HKQuantityTypeIdentifierHeartRateVariabilitySDNN' as const, + respiratoryRate: 'HKQuantityTypeIdentifierRespiratoryRate' as const, + oxygenSaturation: 'HKQuantityTypeIdentifierOxygenSaturation' as const, + + // Body (read-only context) + bodyMass: 'HKQuantityTypeIdentifierBodyMass' as const, + height: 'HKQuantityTypeIdentifierHeight' as const, +}; + +/** All types we request read access for */ +const ALL_READ_TYPES = [ + HK.stepCount, + HK.activeEnergy, + HK.exerciseTime, + HK.moveTime, + HK.standTime, + HK.distance, + HK.sleepAnalysis, + HK.heartRate, + HK.restingHeartRate, + HK.hrv, + HK.respiratoryRate, + HK.oxygenSaturation, + HK.bodyMass, + HK.height, +]; + +/** Types we request write access for (subset) */ +const WRITE_TYPES = [ + HK.stepCount, + HK.activeEnergy, + HK.sleepAnalysis, + HK.heartRate, +]; + +// ── Platform guard ────────────────────────────────────────────────── + +function isIOS(): boolean { + return Platform.OS === 'ios'; +} + +// ── Query helper ──────────────────────────────────────────────────── + +async function queryQuantity( + identifier: string, + range: DateRange, + unit: string, +): Promise { + return queryQuantitySamples(identifier as any, { + limit: 0, // 0 = no limit, fetch all samples in range + unit, + filter: { + date: { + startDate: range.startDate, + endDate: range.endDate, + }, + }, + }); +} + +// ── Public API ────────────────────────────────────────────────────── + +/** + * Request HealthKit permissions for all data types used by the app. + * Must be called before any data queries. + * + * Privacy note: HealthKit always returns "not determined" for read + * permissions regardless of whether the user granted them. This is + * an Apple privacy design — the only way to know if read was granted + * is to attempt a query and see if data comes back. + */ +export async function requestHealthPermissions(): Promise { + if (!isIOS()) { + return { + success: false, + note: 'HealthKit is only available on iOS.', + }; + } + + try { + const available = isHealthDataAvailable(); + if (!available) { + return { + success: false, + note: 'HealthKit is not available on this device.', + }; + } + + await requestAuthorization({ + toRead: ALL_READ_TYPES as any, + toShare: WRITE_TYPES as any, + }); + + return { + success: true, + note: 'Authorization requested. Read permission status is always "not determined" for privacy — this is expected Apple behavior.', + }; + } catch (error) { + return { + success: false, + note: `Permission request failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Get daily activity summaries for a date range. + * Returns one DailyActivity per day. + */ +export async function getDailyActivity(range: DateRange): Promise { + if (!isIOS()) return []; + + // Fetch all activity types in parallel + const [steps, energy, exercise, move, stand, distance] = await Promise.all([ + queryQuantity(HK.stepCount, range, 'count'), + queryQuantity(HK.activeEnergy, range, 'kcal'), + queryQuantity(HK.exerciseTime, range, 'min'), + queryQuantity(HK.moveTime, range, 'min'), + queryQuantity(HK.standTime, range, 'min'), + queryQuantity(HK.distance, range, 'm'), + ]); + + // Bucket each metric by day + const stepsByDay = bucketSamplesByDay(steps); + const energyByDay = bucketSamplesByDay(energy); + const exerciseByDay = bucketSamplesByDay(exercise); + const moveByDay = bucketSamplesByDay(move); + const standByDay = bucketSamplesByDay(stand); + const distanceByDay = bucketSamplesByDay(distance); + + // Build daily summaries + const dateKeys = getDateKeysInRange(range.startDate, range.endDate); + return dateKeys.map((date) => { + const exerciseMin = Math.round(sumSamples(exerciseByDay.get(date) ?? [])); + const moveMin = Math.round(sumSamples(moveByDay.get(date) ?? [])); + const standMin = Math.round(sumSamples(standByDay.get(date) ?? [])); + + return { + date, + steps: Math.round(sumSamples(stepsByDay.get(date) ?? [])), + exerciseMinutes: exerciseMin, + moveMinutes: moveMin, + standMinutes: standMin, + sedentaryMinutes: estimateSedentaryMinutes(exerciseMin, moveMin, standMin), + activeEnergyBurned: Math.round(sumSamples(energyByDay.get(date) ?? [])), + distanceWalkingRunning: Math.round(sumSamples(distanceByDay.get(date) ?? [])), + }; + }); +} + +/** + * Get sleep data for a date range. + * Groups sleep samples into nights with stage breakdowns. + * On iOS 16+, provides detailed Core/Deep/REM stages. + * On older iOS, falls back to "asleep" vs "in bed". + */ +export async function getSleep(range: DateRange): Promise { + if (!isIOS()) return []; + + const rawSamples = await queryCategorySamples(HK.sleepAnalysis, { + limit: 0, + filter: { + date: { + startDate: range.startDate, + endDate: range.endDate, + }, + }, + }); + + if (!rawSamples || rawSamples.length === 0) return []; + + // Convert to our SleepSample type and group by night + const nightMap = new Map[]>(); + + for (const raw of rawSamples) { + const sample = mapCategorySampleToSleepSample(raw as any); + const nightKey = getSleepNightDate(new Date(raw.startDate)); + const bucket = nightMap.get(nightKey) ?? []; + bucket.push(sample); + nightMap.set(nightKey, bucket); + } + + // Aggregate each night + const nights: SleepNight[] = []; + for (const [date, samples] of nightMap) { + let inBedMinutes = 0; + let awakeMinutes = 0; + let coreMinutes = 0; + let deepMinutes = 0; + let remMinutes = 0; + let asleepUndifferentiated = 0; + + for (const s of samples) { + switch (s.stage) { + case SleepStage.InBed: + inBedMinutes += s.durationMinutes; + break; + case SleepStage.Awake: + awakeMinutes += s.durationMinutes; + break; + case SleepStage.Core: + coreMinutes += s.durationMinutes; + break; + case SleepStage.Deep: + deepMinutes += s.durationMinutes; + break; + case SleepStage.REM: + remMinutes += s.durationMinutes; + break; + case SleepStage.AsleepUnspecified: + asleepUndifferentiated += s.durationMinutes; + break; + } + } + + const hasDetailedStages = coreMinutes > 0 || deepMinutes > 0 || remMinutes > 0; + const totalAsleep = hasDetailedStages + ? coreMinutes + deepMinutes + remMinutes + : asleepUndifferentiated; + const totalInBed = inBedMinutes > 0 + ? inBedMinutes + : totalAsleep + awakeMinutes; // fallback if no explicit inBed samples + + const efficiency = totalInBed > 0 + ? Math.round((totalAsleep / totalInBed) * 1000) / 10 + : 0; + + nights.push({ + date, + totalAsleepMinutes: Math.round(totalAsleep), + totalInBedMinutes: Math.round(totalInBed), + sleepEfficiency: efficiency, + hasDetailedStages, + stages: { + awake: Math.round(awakeMinutes), + core: Math.round(coreMinutes), + deep: Math.round(deepMinutes), + rem: Math.round(remMinutes), + asleepUndifferentiated: Math.round(asleepUndifferentiated), + }, + samples, + }); + } + + // Sort by date + nights.sort((a, b) => a.date.localeCompare(b.date)); + return nights; +} + +/** + * Get vitals data for a date range. + * Includes heart rate (min/avg/max), resting HR, HRV, respiratory rate, SpO2. + */ +export async function getVitals(range: DateRange): Promise { + if (!isIOS()) return []; + + // Fetch all vitals in parallel + const [hr, restingHR, hrvSamples, respRate, spo2] = await Promise.all([ + queryQuantity(HK.heartRate, range, 'count/min'), + queryQuantity(HK.restingHeartRate, range, 'count/min'), + queryQuantity(HK.hrv, range, 'ms'), + queryQuantity(HK.respiratoryRate, range, 'count/min'), + queryQuantity(HK.oxygenSaturation, range, '%'), + ]); + + // Bucket by day + const hrByDay = bucketSamplesByDay(hr); + const restingByDay = bucketSamplesByDay(restingHR); + const hrvByDay = bucketSamplesByDay(hrvSamples); + const respByDay = bucketSamplesByDay(respRate); + const spo2ByDay = bucketSamplesByDay(spo2); + + const dateKeys = getDateKeysInRange(range.startDate, range.endDate); + return dateKeys.map((date) => { + const hrDaySamples = hrByDay.get(date) ?? []; + const restingSamples = restingByDay.get(date) ?? []; + const hrvDaySamples = hrvByDay.get(date) ?? []; + const respSamples = respByDay.get(date) ?? []; + const spo2Samples = spo2ByDay.get(date) ?? []; + + return { + date, + heartRate: statsSamples(hrDaySamples), + restingHeartRate: restingSamples.length > 0 + ? Math.round(restingSamples[restingSamples.length - 1].quantity * 10) / 10 + : null, + hrv: hrvDaySamples.length > 0 + ? Math.round(hrvDaySamples[hrvDaySamples.length - 1].quantity * 10) / 10 + : null, + respiratoryRate: respSamples.length > 0 + ? Math.round(respSamples[respSamples.length - 1].quantity * 10) / 10 + : null, + oxygenSaturation: spo2Samples.length > 0 + ? Math.round(spo2Samples[spo2Samples.length - 1].quantity * 100 * 10) / 10 + : null, + }; + }); +} + +// ── Demographics ──────────────────────────────────────────────────── + +const BIOLOGICAL_SEX_LABELS: Record = { + [BiologicalSex.notSet]: null, + [BiologicalSex.female]: 'Female', + [BiologicalSex.male]: 'Male', + [BiologicalSex.other]: 'Other', +}; + +/** + * Get the user's biological sex from HealthKit. + * Returns null if not set or unavailable. + */ +export async function getBiologicalSex(): Promise { + if (!isIOS()) return null; + try { + const sex = await hkGetBiologicalSex(); + return BIOLOGICAL_SEX_LABELS[sex] ?? null; + } catch { + return null; + } +} + +/** + * Get the user's date of birth from HealthKit. + * Returns ISO date string or null if not set. + */ +export async function getDateOfBirth(): Promise { + if (!isIOS()) return null; + try { + const dob = await hkGetDateOfBirth(); + if (!dob || dob.getTime() === 0) return null; + return dob.toISOString().split('T')[0]; + } catch { + return null; + } +} + +/** + * Get demographics (age + biological sex) from HealthKit. + * Combines getDateOfBirth and getBiologicalSex into a single call. + */ +export async function getDemographics(): Promise { + const [dob, sex] = await Promise.all([getDateOfBirth(), getBiologicalSex()]); + + let age: number | null = null; + if (dob) { + const birthDate = new Date(dob); + const today = new Date(); + age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + } + + return { + age, + dateOfBirth: dob, + biologicalSex: sex, + }; +} diff --git a/homeflow/lib/services/healthkit/index.ts b/homeflow/lib/services/healthkit/index.ts new file mode 100644 index 0000000..60e7e8e --- /dev/null +++ b/homeflow/lib/services/healthkit/index.ts @@ -0,0 +1,57 @@ +/** + * HealthKit Service + * + * Clean abstraction for Apple HealthKit data queries. + * Uses @kingstinct/react-native-healthkit under the hood. + * + * Usage: + * import { requestHealthPermissions, getDailyActivity, getSleep, getVitals } from '@/lib/services/healthkit'; + * + * All data collection is gated behind explicit user consent via requestHealthPermissions(). + * No health data is logged in production. Data is normalized to simple units. + */ + +export { + requestHealthPermissions, + getDailyActivity, + getSleep, + getVitals, + getBiologicalSex, + getDateOfBirth, + getDemographics, +} from './HealthKitClient'; + +export { getDateRange } from './mappers'; + +export { SleepStage } from './types'; + +export type { + DateRange, + DailyActivity, + SleepNight, + SleepSample, + VitalsDay, + VitalsSample, + HeartRateStats, + HealthPermissionResult, +} from './types'; + +// ── Clinical Records (FHIR) ──────────────────────────────────────── + +export { + areClinicalRecordsAvailable, + requestClinicalPermissions, + getClinicalMedications, + getClinicalLabResults, + getClinicalConditions, + getClinicalProcedures, + getAllClinicalRecords, +} from './ClinicalRecordsClient'; + +export { ClinicalRecordType } from '@/modules/expo-clinical-records/src'; + +export type { + ClinicalRecord, + ClinicalRecordQueryOptions, + ClinicalRecordsAuthResult, +} from '@/modules/expo-clinical-records/src'; diff --git a/homeflow/lib/services/healthkit/mappers.ts b/homeflow/lib/services/healthkit/mappers.ts new file mode 100644 index 0000000..b375e85 --- /dev/null +++ b/homeflow/lib/services/healthkit/mappers.ts @@ -0,0 +1,165 @@ +/** + * HealthKit Mappers + * + * Maps raw HealthKit data to our normalized types. + */ + +import { CategoryValueSleepAnalysis } from '@kingstinct/react-native-healthkit'; +import type { QuantitySample } from '@kingstinct/react-native-healthkit'; +import { SleepStage, type SleepSample } from './types'; + +// ── Date helpers ──────────────────────────────────────────────────── + +/** Format a Date to YYYY-MM-DD */ +export function formatDateKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; +} + +/** Get start of day (00:00:00.000) in local timezone */ +export function startOfDay(date: Date): Date { + const d = new Date(date); + d.setHours(0, 0, 0, 0); + return d; +} + +/** Get end of day (23:59:59.999) in local timezone */ +export function endOfDay(date: Date): Date { + const d = new Date(date); + d.setHours(23, 59, 59, 999); + return d; +} + +/** Build a DateRange for the last N days (including today) */ +export function getDateRange(days: number): { startDate: Date; endDate: Date } { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - (days - 1)); + return { startDate: startOfDay(start), endDate: endOfDay(end) }; +} + +/** Get all YYYY-MM-DD keys between two dates */ +export function getDateKeysInRange(startDate: Date, endDate: Date): string[] { + const keys: string[] = []; + const current = startOfDay(new Date(startDate)); + const end = startOfDay(new Date(endDate)); + while (current <= end) { + keys.push(formatDateKey(current)); + current.setDate(current.getDate() + 1); + } + return keys; +} + +// ── Quantity sample helpers ───────────────────────────────────────── + +/** Group quantity samples by YYYY-MM-DD */ +export function bucketSamplesByDay( + samples: readonly QuantitySample[], +): Map { + const map = new Map(); + for (const sample of samples) { + const key = formatDateKey(new Date(sample.startDate)); + const bucket = map.get(key) ?? []; + bucket.push(sample); + map.set(key, bucket); + } + return map; +} + +/** Sum all quantity values in a list of samples */ +export function sumSamples(samples: readonly QuantitySample[]): number { + return samples.reduce((sum, s) => sum + s.quantity, 0); +} + +/** Get min/max/avg from samples */ +export function statsSamples(samples: readonly QuantitySample[]): { + min: number; + max: number; + average: number; + sampleCount: number; +} { + if (samples.length === 0) { + return { min: 0, max: 0, average: 0, sampleCount: 0 }; + } + let min = Infinity; + let max = -Infinity; + let sum = 0; + for (const s of samples) { + if (s.quantity < min) min = s.quantity; + if (s.quantity > max) max = s.quantity; + sum += s.quantity; + } + return { + min: Math.round(min * 10) / 10, + max: Math.round(max * 10) / 10, + average: Math.round((sum / samples.length) * 10) / 10, + sampleCount: samples.length, + }; +} + +// ── Sleep mappers ─────────────────────────────────────────────────── + +/** Map HKCategoryValueSleepAnalysis to our SleepStage enum */ +export function mapSleepValue(value: number): SleepStage { + switch (value) { + case CategoryValueSleepAnalysis.inBed: + return SleepStage.InBed; + case CategoryValueSleepAnalysis.awake: + return SleepStage.Awake; + case CategoryValueSleepAnalysis.asleepCore: + return SleepStage.Core; + case CategoryValueSleepAnalysis.asleepDeep: + return SleepStage.Deep; + case CategoryValueSleepAnalysis.asleepREM: + return SleepStage.REM; + case CategoryValueSleepAnalysis.asleepUnspecified: + return SleepStage.AsleepUnspecified; + default: + return SleepStage.AsleepUnspecified; + } +} + +/** Convert a raw HK sleep category sample to our SleepSample */ +export function mapCategorySampleToSleepSample(raw: { + value: number; + startDate: Date; + endDate: Date; +}): SleepSample { + const start = new Date(raw.startDate); + const end = new Date(raw.endDate); + const durationMinutes = Math.round((end.getTime() - start.getTime()) / 60000); + return { + stage: mapSleepValue(raw.value), + startDate: start.toISOString(), + endDate: end.toISOString(), + durationMinutes: Math.max(0, durationMinutes), + }; +} + +/** + * Determine the "night date" for a sleep sample. + * Sleep that starts before 6 PM belongs to the previous night. + * Sleep that starts after 6 PM belongs to that night's date. + */ +export function getSleepNightDate(startDate: Date): string { + const d = new Date(startDate); + // If sleep started before 6 PM, it likely belongs to the previous night + if (d.getHours() < 18) { + d.setDate(d.getDate() - 1); + } + return formatDateKey(d); +} + +/** Calculate sedentary minutes estimate */ +export function estimateSedentaryMinutes( + exerciseMinutes: number, + moveMinutes: number, + standMinutes: number, +): number { + // Assume 16 waking hours = 960 minutes + const WAKING_MINUTES = 960; + const active = exerciseMinutes + moveMinutes + standMinutes; + return Math.max(0, Math.round(WAKING_MINUTES - active)); +} diff --git a/homeflow/lib/services/healthkit/types.ts b/homeflow/lib/services/healthkit/types.ts new file mode 100644 index 0000000..085cb4a --- /dev/null +++ b/homeflow/lib/services/healthkit/types.ts @@ -0,0 +1,155 @@ +/** + * HealthKit Service Types + * + * Normalized data types for health metrics from Apple HealthKit. + * All values use simple units (counts, minutes, bpm, ms, etc.) + * with ISO timestamps and timezone info. + */ + +// ── Date range for queries ────────────────────────────────────────── + +export interface DateRange { + startDate: Date; + endDate: Date; +} + +// ── Daily Activity ────────────────────────────────────────────────── + +export interface DailyActivity { + /** YYYY-MM-DD */ + date: string; + /** Total step count for the day */ + steps: number; + /** Apple Exercise minutes (vigorous activity detected by Watch) */ + exerciseMinutes: number; + /** Apple Move minutes (any movement above sedentary threshold) */ + moveMinutes: number; + /** Apple Stand minutes (minutes with at least 1 min standing per hour) */ + standMinutes: number; + /** + * Estimated sedentary minutes. + * Approximation: 960 (16h waking) - exerciseMinutes - moveMinutes - standMinutes. + * Limitation: This is a rough estimate. Apple does not expose a direct + * "sedentary time" metric. The calculation assumes ~16 waking hours and + * subtracts known active periods. Actual sedentary time may differ. + */ + sedentaryMinutes: number; + /** Active energy burned in kcal */ + activeEnergyBurned: number; + /** Walking + running distance in meters */ + distanceWalkingRunning: number; +} + +// ── Sleep ─────────────────────────────────────────────────────────── + +export enum SleepStage { + InBed = 'inBed', + Awake = 'awake', + Core = 'core', + Deep = 'deep', + REM = 'rem', + AsleepUnspecified = 'asleepUnspecified', +} + +export interface SleepSample { + stage: SleepStage; + startDate: string; // ISO 8601 + endDate: string; // ISO 8601 + durationMinutes: number; +} + +export interface SleepNight { + /** YYYY-MM-DD of the night (date sleep started) */ + date: string; + totalAsleepMinutes: number; + totalInBedMinutes: number; + /** (totalAsleep / totalInBed) * 100, rounded to 1 decimal */ + sleepEfficiency: number; + /** true if iOS 16+ detailed stage data is available */ + hasDetailedStages: boolean; + stages: { + awake: number; + core: number; + deep: number; + rem: number; + /** Fallback for older iOS without stage breakdown */ + asleepUndifferentiated: number; + }; + /** Raw samples for this night */ + samples: SleepSample[]; +} + +// ── Vitals ────────────────────────────────────────────────────────── + +export interface HeartRateStats { + min: number; // bpm + max: number; // bpm + average: number; // bpm + sampleCount: number; +} + +export interface VitalsSample { + value: number; + unit: string; + startDate: string; // ISO 8601 + endDate: string; // ISO 8601 + sourceName?: string; +} + +export interface VitalsDay { + /** YYYY-MM-DD */ + date: string; + heartRate: HeartRateStats; + /** bpm, from Apple Watch overnight analysis. null if unavailable. */ + restingHeartRate: number | null; + /** SDNN in milliseconds. null if unavailable. */ + hrv: number | null; + /** Breaths per minute. null if unavailable. */ + respiratoryRate: number | null; + /** Percentage (0-100). null if unavailable. */ + oxygenSaturation: number | null; +} + +// ── Permission result ─────────────────────────────────────────────── + +export interface HealthPermissionResult { + success: boolean; + /** HealthKit always returns "not determined" for read permissions (privacy). */ + note: string; +} + +// ── Metrics we support vs. don't ──────────────────────────────────── + +/** + * Metrics currently implemented: + * - Step count (daily total) + * - Exercise minutes (Apple Exercise Time) + * - Move minutes (Apple Move Time) + * - Stand minutes (Apple Stand Time) + * - Sedentary time (estimated) + * - Active energy burned + * - Distance walking/running + * - Sleep stages (Core/Deep/REM/Awake) with iOS 16+ fallback + * - Heart rate (min/avg/max per day) + * - Resting heart rate + * - Heart rate variability (HRV SDNN) + * - Respiratory rate + * - Oxygen saturation (SpO2) + * + * Metrics NOT currently implemented but available via HealthKit + Apple Watch: + * - VO2 Max + * - Walking heart rate average + * - Apple Walking Steadiness + * - Walking speed, step length, asymmetry + * - Stair ascent/descent speed + * - Running metrics (pace, cadence, ground contact time, power) + * - Cycling metrics (speed, power, cadence) + * - Blood pressure + * - Body temperature / wrist temperature + * - Blood glucose + * - Electrocardiogram (ECG) + * - Environmental/headphone audio exposure + * - Time in daylight + * - Workouts (detailed workout sessions) + * - Mindfulness sessions + */ diff --git a/homeflow/lib/services/onboarding-service.ts b/homeflow/lib/services/onboarding-service.ts index 0cbf239..11136a6 100644 --- a/homeflow/lib/services/onboarding-service.ts +++ b/homeflow/lib/services/onboarding-service.ts @@ -41,6 +41,7 @@ export interface OnboardingData { // Permissions status permissions?: { healthKit: 'granted' | 'denied' | 'not_determined'; + clinicalRecords: 'granted' | 'denied' | 'not_determined' | 'skipped'; throne: 'granted' | 'denied' | 'not_determined' | 'skipped'; }; @@ -66,6 +67,7 @@ interface OnboardingState { class OnboardingServiceImpl { private state: OnboardingState | null = null; private initialized = false; + private finished = false; /** * Initialize the service by loading state from AsyncStorage @@ -76,6 +78,7 @@ class OnboardingServiceImpl { try { const stepData = await AsyncStorage.getItem(STORAGE_KEYS.ONBOARDING_STEP); const savedData = await AsyncStorage.getItem(STORAGE_KEYS.ONBOARDING_DATA); + const finishedData = await AsyncStorage.getItem(STORAGE_KEYS.ONBOARDING_FINISHED); if (stepData) { this.state = { @@ -86,6 +89,7 @@ class OnboardingServiceImpl { }; } + this.finished = finishedData === 'true'; this.initialized = true; } catch (error) { console.error('Failed to initialize onboarding service:', error); @@ -102,11 +106,11 @@ class OnboardingServiceImpl { } /** - * Check if onboarding is complete + * Check if onboarding is complete (user clicked "Get Started") */ async isComplete(): Promise { await this.initialize(); - return this.state?.currentStep === OnboardingStep.COMPLETE; + return this.finished; } /** @@ -207,7 +211,7 @@ class OnboardingServiceImpl { } /** - * Complete onboarding + * Complete onboarding (called when user clicks "Get Started") */ async complete(): Promise { await this.initialize(); @@ -217,6 +221,10 @@ class OnboardingServiceImpl { this.state.lastUpdatedAt = new Date().toISOString(); await this.persistState(); } + + // Mark onboarding as finished (distinct from reaching the complete screen) + this.finished = true; + await AsyncStorage.setItem(STORAGE_KEYS.ONBOARDING_FINISHED, 'true'); } /** @@ -225,10 +233,12 @@ class OnboardingServiceImpl { async reset(): Promise { this.state = null; this.initialized = false; + this.finished = false; await AsyncStorage.multiRemove([ STORAGE_KEYS.ONBOARDING_STEP, STORAGE_KEYS.ONBOARDING_DATA, + STORAGE_KEYS.ONBOARDING_FINISHED, STORAGE_KEYS.CONSENT_GIVEN, STORAGE_KEYS.CONSENT_DATE, STORAGE_KEYS.CONSENT_VERSION, diff --git a/homeflow/modules/expo-clinical-records/expo-module.config.json b/homeflow/modules/expo-clinical-records/expo-module.config.json new file mode 100644 index 0000000..6a0d14d --- /dev/null +++ b/homeflow/modules/expo-clinical-records/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["ClinicalRecordsModule"] + } +} diff --git a/homeflow/modules/expo-clinical-records/ios/ClinicalRecordsModule.podspec b/homeflow/modules/expo-clinical-records/ios/ClinicalRecordsModule.podspec new file mode 100644 index 0000000..ba31e64 --- /dev/null +++ b/homeflow/modules/expo-clinical-records/ios/ClinicalRecordsModule.podspec @@ -0,0 +1,18 @@ +Pod::Spec.new do |s| + s.name = 'ClinicalRecordsModule' + s.version = '0.1.0' + s.summary = 'Expo module for Apple Health Clinical Records (FHIR)' + s.description = 'Queries HKClinicalRecord types and returns raw FHIR R4 JSON payloads.' + s.homepage = 'https://github.com/CS342/2026-Stream' + s.license = { type: 'MIT' } + s.author = 'Stream Team' + s.source = { git: '' } + + s.platform = :ios, '16.0' + s.swift_version = '5.9' + + s.source_files = '**/*.swift' + s.frameworks = 'HealthKit' + + s.dependency 'ExpoModulesCore' +end diff --git a/homeflow/modules/expo-clinical-records/ios/ClinicalRecordsModule.swift b/homeflow/modules/expo-clinical-records/ios/ClinicalRecordsModule.swift new file mode 100644 index 0000000..fb9382f --- /dev/null +++ b/homeflow/modules/expo-clinical-records/ios/ClinicalRecordsModule.swift @@ -0,0 +1,157 @@ +import ExpoModulesCore +import HealthKit + +public class ClinicalRecordsModule: Module { + private lazy var healthStore = HKHealthStore() + + // Map short string identifiers to HKClinicalTypeIdentifier + private static let typeMap: [String: HKClinicalTypeIdentifier] = [ + "allergyRecord": .allergyRecord, + "conditionRecord": .conditionRecord, + "immunizationRecord": .immunizationRecord, + "labResultRecord": .labResultRecord, + "medicationRecord": .medicationRecord, + "procedureRecord": .procedureRecord, + "vitalSignRecord": .vitalSignRecord, + ] + + public func definition() -> ModuleDefinition { + Name("ExpoClinicalRecords") + + // Check if clinical records are available on this device + Function("isAvailable") { () -> Bool in + guard HKHealthStore.isHealthDataAvailable() else { return false } + return self.healthStore.supportsHealthRecords() + } + + // Return the list of supported clinical type identifiers + Function("getSupportedTypes") { () -> [String] in + return Array(ClinicalRecordsModule.typeMap.keys).sorted() + } + + // Request authorization for the given clinical record types + AsyncFunction("requestAuthorization") { (typeNames: [String], promise: Promise) in + guard HKHealthStore.isHealthDataAvailable(), + self.healthStore.supportsHealthRecords() else { + promise.resolve(["success": false, "note": "Clinical records not supported on this device."]) + return + } + + var types = Set() + for name in typeNames { + guard let identifier = ClinicalRecordsModule.typeMap[name], + let clinicalType = HKObjectType.clinicalType(forIdentifier: identifier) else { + continue + } + types.insert(clinicalType) + } + + if types.isEmpty { + promise.resolve(["success": false, "note": "No valid clinical record types provided."]) + return + } + + self.healthStore.requestAuthorization(toShare: nil, read: types) { success, error in + if let error = error { + promise.resolve(["success": false, "note": error.localizedDescription]) + } else { + promise.resolve([ + "success": success, + "note": success + ? "Authorization requested. Read permission status is always 'not determined' for privacy." + : "Authorization was not granted.", + ]) + } + } + } + + // Query clinical records of a given type + AsyncFunction("getClinicalRecords") { (typeName: String, options: [String: Any]?, promise: Promise) in + guard HKHealthStore.isHealthDataAvailable(), + self.healthStore.supportsHealthRecords() else { + promise.resolve([] as [[String: Any]]) + return + } + + guard let identifier = ClinicalRecordsModule.typeMap[typeName], + let clinicalType = HKObjectType.clinicalType(forIdentifier: identifier) else { + promise.resolve([] as [[String: Any]]) + return + } + + // Build optional date predicate + var predicate: NSPredicate? = nil + if let opts = options { + let startDate = (opts["startDate"] as? String).flatMap { self.parseISO8601($0) } + let endDate = (opts["endDate"] as? String).flatMap { self.parseISO8601($0) } + if startDate != nil || endDate != nil { + predicate = HKQuery.predicateForSamples( + withStart: startDate, + end: endDate, + options: .strictStartDate + ) + } + } + + let limit = (options?["limit"] as? Int) ?? HKObjectQueryNoLimit + + let query = HKSampleQuery( + sampleType: clinicalType, + predicate: predicate, + limit: limit, + sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] + ) { _, samples, error in + guard error == nil, let records = samples as? [HKClinicalRecord] else { + promise.resolve([] as [[String: Any]]) + return + } + + let results: [[String: Any]] = records.compactMap { record in + self.serializeClinicalRecord(record) + } + + promise.resolve(results) + } + + self.healthStore.execute(query) + } + } + + // MARK: - Helpers + + private func serializeClinicalRecord(_ record: HKClinicalRecord) -> [String: Any] { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + + var dict: [String: Any] = [ + "id": record.uuid.uuidString, + "clinicalType": record.clinicalType.identifier, + "displayName": record.displayName, + "startDate": formatter.string(from: record.startDate), + "endDate": formatter.string(from: record.endDate), + ] + + // Extract FHIR resource data if available + if let fhirRecord = record.fhirResource { + dict["fhirResourceType"] = fhirRecord.resourceType.rawValue + dict["fhirIdentifier"] = fhirRecord.identifier + dict["fhirSourceURL"] = fhirRecord.sourceURL?.absoluteString + + // Parse the raw FHIR JSON data + if let json = try? JSONSerialization.jsonObject(with: fhirRecord.data, options: []) { + dict["fhirResource"] = json + } + } + + return dict + } + + private func parseISO8601(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.date(from: string) ?? { + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + }() + } +} diff --git a/homeflow/modules/expo-clinical-records/src/ClinicalRecords.ts b/homeflow/modules/expo-clinical-records/src/ClinicalRecords.ts new file mode 100644 index 0000000..d4beff3 --- /dev/null +++ b/homeflow/modules/expo-clinical-records/src/ClinicalRecords.ts @@ -0,0 +1,87 @@ +/** + * Clinical Records API + * + * Platform-guarded public API for Apple Health Clinical Records. + * Returns empty/default values on non-iOS platforms or when the + * native module is not available. + */ + +import { Platform } from 'react-native'; +import ExpoClinicalRecords from './ExpoClinicalRecords'; +import type { + ClinicalRecord, + ClinicalRecordQueryOptions, + ClinicalRecordsAuthResult, +} from './ClinicalRecords.types'; +import { ClinicalRecordType } from './ClinicalRecords.types'; + +/** + * Check if clinical records are available on this device. + * Returns false on non-iOS, simulators without Health Records, or + * when the native module isn't compiled. + */ +export function isClinicalRecordsAvailable(): boolean { + if (Platform.OS !== 'ios' || !ExpoClinicalRecords) { + return false; + } + try { + return ExpoClinicalRecords.isAvailable(); + } catch { + return false; + } +} + +/** + * Request authorization to read clinical record types. + * The user will see the standard HealthKit authorization sheet. + * + * Privacy note: HealthKit always returns "not determined" for read + * permissions — this is expected Apple behavior. + */ +export async function requestClinicalRecordsAuthorization( + types: ClinicalRecordType[] = Object.values(ClinicalRecordType), +): Promise { + if (Platform.OS !== 'ios' || !ExpoClinicalRecords) { + return { success: false, note: 'Clinical records are only available on iOS.' }; + } + try { + return await ExpoClinicalRecords.requestAuthorization(types); + } catch (error) { + return { + success: false, + note: `Authorization failed: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} + +/** + * Query clinical records of a specific type. + * Returns an empty array if not available or on error. + */ +export async function getClinicalRecords( + type: ClinicalRecordType, + options?: ClinicalRecordQueryOptions, +): Promise { + if (Platform.OS !== 'ios' || !ExpoClinicalRecords) { + return []; + } + try { + return await ExpoClinicalRecords.getClinicalRecords(type, options ?? null); + } catch { + return []; + } +} + +/** + * Get the list of supported clinical record type identifiers. + */ +export function getSupportedTypes(): string[] { + if (Platform.OS !== 'ios' || !ExpoClinicalRecords) { + return []; + } + try { + return ExpoClinicalRecords.getSupportedTypes(); + } catch { + return []; + } +} diff --git a/homeflow/modules/expo-clinical-records/src/ClinicalRecords.types.ts b/homeflow/modules/expo-clinical-records/src/ClinicalRecords.types.ts new file mode 100644 index 0000000..4018e05 --- /dev/null +++ b/homeflow/modules/expo-clinical-records/src/ClinicalRecords.types.ts @@ -0,0 +1,55 @@ +/** + * Clinical Records Types + * + * Types for Apple Health Clinical Records (FHIR R4). + * These map to HKClinicalTypeIdentifier values in HealthKit. + */ + +/** Supported HKClinicalRecord type identifiers */ +export enum ClinicalRecordType { + AllergyRecord = 'allergyRecord', + ConditionRecord = 'conditionRecord', + ImmunizationRecord = 'immunizationRecord', + LabResultRecord = 'labResultRecord', + MedicationRecord = 'medicationRecord', + ProcedureRecord = 'procedureRecord', + VitalSignRecord = 'vitalSignRecord', +} + +/** A single clinical record returned from HealthKit */ +export interface ClinicalRecord { + /** UUID of the HKClinicalRecord */ + id: string; + /** The HKClinicalType identifier (e.g. "HKClinicalTypeIdentifierMedicationRecord") */ + clinicalType: string; + /** Human-readable name from the health record */ + displayName: string; + /** ISO 8601 start date */ + startDate: string; + /** ISO 8601 end date */ + endDate: string; + /** FHIR resource type (e.g. "MedicationOrder", "Condition") */ + fhirResourceType?: string; + /** FHIR resource identifier */ + fhirIdentifier?: string; + /** Source URL of the FHIR resource */ + fhirSourceURL?: string; + /** The raw FHIR R4 JSON resource */ + fhirResource?: Record; +} + +/** Options for querying clinical records */ +export interface ClinicalRecordQueryOptions { + /** ISO 8601 start date filter */ + startDate?: string; + /** ISO 8601 end date filter */ + endDate?: string; + /** Maximum number of records to return (0 = no limit) */ + limit?: number; +} + +/** Result of a clinical records authorization request */ +export interface ClinicalRecordsAuthResult { + success: boolean; + note: string; +} diff --git a/homeflow/modules/expo-clinical-records/src/ExpoClinicalRecords.ts b/homeflow/modules/expo-clinical-records/src/ExpoClinicalRecords.ts new file mode 100644 index 0000000..6c4dd87 --- /dev/null +++ b/homeflow/modules/expo-clinical-records/src/ExpoClinicalRecords.ts @@ -0,0 +1,4 @@ +import { requireOptionalNativeModule } from 'expo-modules-core'; + +// Returns null on non-iOS platforms or when the native module isn't compiled in. +export default requireOptionalNativeModule('ExpoClinicalRecords'); diff --git a/homeflow/modules/expo-clinical-records/src/index.ts b/homeflow/modules/expo-clinical-records/src/index.ts new file mode 100644 index 0000000..86e6b1a --- /dev/null +++ b/homeflow/modules/expo-clinical-records/src/index.ts @@ -0,0 +1,14 @@ +export { + isClinicalRecordsAvailable, + requestClinicalRecordsAuthorization, + getClinicalRecords, + getSupportedTypes, +} from './ClinicalRecords'; + +export { ClinicalRecordType } from './ClinicalRecords.types'; + +export type { + ClinicalRecord, + ClinicalRecordQueryOptions, + ClinicalRecordsAuthResult, +} from './ClinicalRecords.types'; diff --git a/homeflow/package-lock.json b/homeflow/package-lock.json index b6799ef..39e89fd 100644 --- a/homeflow/package-lock.json +++ b/homeflow/package-lock.json @@ -16,17 +16,17 @@ "@ai-sdk/openai": "^3.0.1", "@blazejkustra/react-native-alert": "^1.0.0", "@expo/vector-icons": "^15.0.3", - "@kingstinct/react-native-healthkit": "^9.0.0", + "@kingstinct/react-native-healthkit": "^13.1.1", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.9.0", "@react-navigation/elements": "^2.9.3", "@react-navigation/native": "^7.1.26", "@spezivibe/chat": "*", - "@spezivibe/healthkit": "*", "@spezivibe/questionnaire": "*", "@spezivibe/scheduler": "*", "ai": "^6.0.3", "expo": "~54.0.30", + "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.12", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", @@ -43,7 +43,7 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-nitro-modules": "^0.24.0", + "react-native-nitro-modules": "^0.33.7", "react-native-reanimated": "~4.1.6", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", @@ -3441,109 +3441,6 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -3975,18 +3872,18 @@ } }, "node_modules/@kingstinct/react-native-healthkit": { - "version": "9.0.11", - "resolved": "https://registry.npmjs.org/@kingstinct/react-native-healthkit/-/react-native-healthkit-9.0.11.tgz", - "integrity": "sha512-9FQMIzX59r9aotDZVLjm+M8I3N/9wScKTfrgC8z+pn77v5SY0w4KFDXQO9YHYOzQJxDOV0yC4n4XTieb+vVmRA==", + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/@kingstinct/react-native-healthkit/-/react-native-healthkit-13.1.1.tgz", + "integrity": "sha512-WdIA8yTmm92WMJF0DOaeHFKt3vU6dMMWCOq3pCYw62eIkZIfv2C/jmIJW+RPQ4qk3o3hy8VF9FmbDwlREfK6hw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/kingstinct" }, "peerDependencies": { - "react": "*", - "react-native": "*", - "react-native-nitro-modules": "*" + "react": ">=19", + "react-native": ">=0.79", + "react-native-nitro-modules": ">=0.30" } }, "node_modules/@napi-rs/wasm-runtime": { @@ -4021,17 +3918,6 @@ "node": ">=8.0.0" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -4747,10 +4633,6 @@ "resolved": "packages/chat", "link": true }, - "node_modules/@spezivibe/healthkit": { - "resolved": "packages/healthkit", - "link": true - }, "node_modules/@spezivibe/questionnaire": { "resolved": "packages/questionnaire", "link": true @@ -7194,13 +7076,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8083,6 +7958,53 @@ "react-native": "*" } }, + "node_modules/expo-build-properties": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-1.0.10.tgz", + "integrity": "sha512-mFCZbrbrv0AP5RB151tAoRzwRJelqM7bCJzCkxpu+owOyH+p/rFC/q7H5q8B9EpVWj8etaIuszR+gKwohpmu1Q==", + "license": "MIT", + "dependencies": { + "ajv": "^8.11.0", + "semver": "^7.6.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-build-properties/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/expo-build-properties/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/expo-build-properties/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/expo-constants": { "version": "18.0.13", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", @@ -8778,6 +8700,22 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -8946,36 +8884,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/formik": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", @@ -10308,22 +10216,6 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/jalali-plugin-dayjs": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/jalali-plugin-dayjs/-/jalali-plugin-dayjs-1.1.4.tgz", @@ -12641,13 +12533,6 @@ "node": ">=6" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -12734,30 +12619,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -13216,10 +13077,9 @@ } }, "node_modules/react-native-nitro-modules": { - "version": "0.24.1", - "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.24.1.tgz", - "integrity": "sha512-wA04fykLsIObAd/2h0mfl3CGZ7Iqtj9CjnHD9j+yAnMCcYDWYlCV8MICmicWgN6eg4yjrEUDsrfFAYWgCFiCBw==", - "hasInstallScript": true, + "version": "0.33.7", + "resolved": "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.33.7.tgz", + "integrity": "sha512-WepMobWe4j1Ae5GQ5RxYGBdBpJBwzP6zaOxJ7r6nhbY5iyl01DL3Gsh4gk8edzNFRuAh1rvXDAHIipq8SahxeQ==", "license": "MIT", "peerDependencies": { "react": "*", @@ -14421,22 +14281,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -14547,20 +14391,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -15942,25 +15772,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -16318,6 +16129,7 @@ "packages/healthkit": { "name": "@spezivibe/healthkit", "version": "1.0.0", + "extraneous": true, "license": "MIT", "devDependencies": { "@babel/preset-env": "^7.28.5", @@ -16351,213 +16163,6 @@ } } }, - "packages/healthkit/node_modules/@babel/code-frame": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", - "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/highlight": "^7.10.4" - } - }, - "packages/healthkit/node_modules/@expo/config": { - "version": "11.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-11.0.13.tgz", - "integrity": "sha512-TnGb4u/zUZetpav9sx/3fWK71oCPaOjZHoVED9NaEncktAd0Eonhq5NUghiJmkUGt3gGSjRAEBXiBbbY9/B1LA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~10.1.2", - "@expo/config-types": "^53.0.5", - "@expo/json-file": "^9.1.5", - "deepmerge": "^4.3.1", - "getenv": "^2.0.0", - "glob": "^10.4.2", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0", - "resolve-workspace-root": "^2.0.0", - "semver": "^7.6.0", - "slugify": "^1.3.4", - "sucrase": "3.35.0" - } - }, - "packages/healthkit/node_modules/@expo/config-plugins": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-10.1.2.tgz", - "integrity": "sha512-IMYCxBOcnuFStuK0Ay+FzEIBKrwW8OVUMc65+v0+i7YFIIe8aL342l7T4F8lR4oCfhXn7d6M5QPgXvjtc/gAcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expo/config-types": "^53.0.5", - "@expo/json-file": "~9.1.5", - "@expo/plist": "^0.3.5", - "@expo/sdk-runtime-versions": "^1.0.0", - "chalk": "^4.1.2", - "debug": "^4.3.5", - "getenv": "^2.0.0", - "glob": "^10.4.2", - "resolve-from": "^5.0.0", - "semver": "^7.5.4", - "slash": "^3.0.0", - "slugify": "^1.6.6", - "xcode": "^3.0.1", - "xml2js": "0.6.0" - } - }, - "packages/healthkit/node_modules/@expo/config-types": { - "version": "53.0.5", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-53.0.5.tgz", - "integrity": "sha512-kqZ0w44E+HEGBjy+Lpyn0BVL5UANg/tmNixxaRMLS6nf37YsDrLk2VMAmeKMMk5CKG0NmOdVv3ngeUjRQMsy9g==", - "dev": true, - "license": "MIT" - }, - "packages/healthkit/node_modules/@expo/env": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-1.0.7.tgz", - "integrity": "sha512-qSTEnwvuYJ3umapO9XJtrb1fAqiPlmUUg78N0IZXXGwQRt+bkp0OBls+Y5Mxw/Owj8waAM0Z3huKKskRADR5ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", - "getenv": "^2.0.0" - } - }, - "packages/healthkit/node_modules/@expo/json-file": { - "version": "9.1.5", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-9.1.5.tgz", - "integrity": "sha512-prWBhLUlmcQtvN6Y7BpW2k9zXGd3ySa3R6rAguMJkp1z22nunLN64KYTUWfijFlprFoxm9r2VNnGkcbndAlgKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "~7.10.4", - "json5": "^2.2.3" - } - }, - "packages/healthkit/node_modules/@expo/plist": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.3.5.tgz", - "integrity": "sha512-9RYVU1iGyCJ7vWfg3e7c/NVyMFs8wbl+dMWZphtFtsqyN9zppGREU3ctlD3i8KUE0sCUTVnLjCWr+VeUIDep2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", - "xmlbuilder": "^15.1.1" - } - }, - "packages/healthkit/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "packages/healthkit/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "packages/healthkit/node_modules/expo-constants": { - "version": "17.1.8", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.1.8.tgz", - "integrity": "sha512-sOCeMN/BWLA7hBP6lMwoEQzFNgTopk6YY03sBAmwT216IHyL54TjNseg8CRU1IQQ/+qinJ2fYWCl7blx2TiNcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@expo/config": "~11.0.13", - "@expo/env": "~1.0.7" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, - "packages/healthkit/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/healthkit/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "packages/healthkit/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "packages/healthkit/node_modules/sucrase": { - "version": "3.35.0", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", - "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "glob": "^10.3.10", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "packages/questionnaire": { "name": "@spezivibe/questionnaire", "version": "1.0.0", diff --git a/homeflow/package.json b/homeflow/package.json index d89aff2..9c68ff6 100644 --- a/homeflow/package.json +++ b/homeflow/package.json @@ -4,8 +4,8 @@ "version": "1.0.0", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "web": "expo start --web", "lint": "expo lint", "typecheck": "tsc --noEmit", @@ -19,17 +19,17 @@ "@ai-sdk/openai": "^3.0.1", "@blazejkustra/react-native-alert": "^1.0.0", "@expo/vector-icons": "^15.0.3", - "@kingstinct/react-native-healthkit": "^9.0.0", + "@kingstinct/react-native-healthkit": "^13.1.1", "@react-native-async-storage/async-storage": "^2.2.0", "@react-navigation/bottom-tabs": "^7.9.0", "@react-navigation/elements": "^2.9.3", "@react-navigation/native": "^7.1.26", "@spezivibe/chat": "*", - "@spezivibe/healthkit": "*", "@spezivibe/questionnaire": "*", "@spezivibe/scheduler": "*", "ai": "^6.0.3", "expo": "~54.0.30", + "expo-build-properties": "~1.0.10", "expo-constants": "~18.0.12", "expo-font": "~14.0.10", "expo-haptics": "~15.0.8", @@ -46,7 +46,7 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-gesture-handler": "~2.28.0", - "react-native-nitro-modules": "^0.24.0", + "react-native-nitro-modules": "^0.33.7", "react-native-reanimated": "~4.1.6", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", diff --git a/homeflow/packages/chat/src/components/ChatView.tsx b/homeflow/packages/chat/src/components/ChatView.tsx index 892b68a..97a1748 100644 --- a/homeflow/packages/chat/src/components/ChatView.tsx +++ b/homeflow/packages/chat/src/components/ChatView.tsx @@ -43,6 +43,10 @@ export function ChatView({ const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); + // Ref keeps latest messages for use in callbacks without stale closures + const messagesRef = useRef(messages); + messagesRef.current = messages; + // Auto-scroll to bottom when new messages arrive useEffect(() => { if (messages.length > 0) { @@ -72,59 +76,58 @@ export function ChatView({ setInput(''); setIsLoading(true); - // Build messages for LLM - const llmMessages: LLMMessage[] = []; - if (systemPrompt) { - llmMessages.push({ role: 'system', content: systemPrompt }); - } - // Add previous messages - messages.forEach((msg) => { - if (msg.role !== 'system') { - llmMessages.push({ role: msg.role, content: msg.content }); + try { + // Build messages for LLM (read from ref to avoid stale closure) + const llmMessages: LLMMessage[] = []; + if (systemPrompt) { + llmMessages.push({ role: 'system', content: systemPrompt }); } - }); - llmMessages.push({ role: 'user', content: userMessage.content }); - - abortControllerRef.current = new AbortController(); - - // Track accumulated content for the callback - let fullContent = ''; - - await streamChatCompletion( - llmMessages, - provider, - { - onToken: (token) => { - fullContent += token; - setMessages((prev) => - prev.map((msg) => - msg.id === assistantMessage.id - ? { ...msg, content: msg.content + token } - : msg - ) - ); - }, - onComplete: () => { - setIsLoading(false); - abortControllerRef.current = null; - onResponse?.(fullContent); + messagesRef.current.forEach((msg) => { + if (msg.role !== 'system') { + llmMessages.push({ role: msg.role, content: msg.content }); + } + }); + llmMessages.push({ role: 'user', content: userMessage.content }); + + abortControllerRef.current = new AbortController(); + + // Track accumulated content for the callback + let fullContent = ''; + + await streamChatCompletion( + llmMessages, + provider, + { + onToken: (token) => { + fullContent += token; + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessage.id + ? { ...msg, content: msg.content + token } + : msg + ) + ); + }, + onComplete: () => { + onResponse?.(fullContent); + }, + onError: (error) => { + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessage.id + ? { ...msg, content: `Error: ${error.message}` } + : msg + ) + ); + }, }, - onError: (error) => { - setIsLoading(false); - abortControllerRef.current = null; - // Update the assistant message with error - setMessages((prev) => - prev.map((msg) => - msg.id === assistantMessage.id - ? { ...msg, content: `Error: ${error.message}` } - : msg - ) - ); - }, - }, - abortControllerRef.current.signal - ); - }, [input, isLoading, messages, provider, systemPrompt]); + abortControllerRef.current.signal + ); + } finally { + setIsLoading(false); + abortControllerRef.current = null; + } + }, [input, isLoading, provider, systemPrompt, onResponse]); const handleStop = useCallback(() => { abortControllerRef.current?.abort(); @@ -153,6 +156,7 @@ export function ChatView({ item.id} renderItem={renderItem} contentContainerStyle={[ diff --git a/homeflow/packages/chat/src/services/llm.ts b/homeflow/packages/chat/src/services/llm.ts index 4c00c97..6447f54 100644 --- a/homeflow/packages/chat/src/services/llm.ts +++ b/homeflow/packages/chat/src/services/llm.ts @@ -69,8 +69,14 @@ export async function streamChatCompletion( abortSignal, }); + let tokenCount = 0; for await (const chunk of result.textStream) { callbacks.onToken(chunk); + // Yield to the UI thread periodically so React can paint between tokens. + // Prevents stalls when expo/fetch delivers chunks in bursts. + if (++tokenCount % 8 === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } } callbacks.onComplete(); } catch (error) { diff --git a/homeflow/packages/healthkit/README.md b/homeflow/packages/healthkit/README.md deleted file mode 100644 index d320a5b..0000000 --- a/homeflow/packages/healthkit/README.md +++ /dev/null @@ -1,788 +0,0 @@ -# @spezivibe/healthkit - -A React Native package for Apple HealthKit integration in Expo apps. Provides configurable health data collection, React hooks, and pre-built UI components. Built on [@kingstinct/react-native-healthkit](https://github.com/kingstinct/react-native-healthkit). - -## Table of Contents - -- [Features](#features) -- [Requirements](#requirements) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Configuration](#configuration) -- [Components](#components) -- [Hooks](#hooks) -- [Theming](#theming) -- [API Reference](#api-reference) -- [Sample Types](#sample-types) -- [Platform Support](#platform-support) -- [Examples](#examples) -- [Troubleshooting](#troubleshooting) - -## Features - -- **50+ Health Metrics** - Steps, heart rate, sleep, blood glucose, and more -- **Configurable Collection** - Declarative API to specify which data to collect -- **React Hooks** - `useHealthKit` and `useHealthMetric` for easy data access -- **Pre-built UI** - Ready-to-use `HealthView` dashboard and `MetricCard` components -- **Expo Go Detection** - Automatic fallback UI when running in Expo Go -- **TypeScript** - Full TypeScript support with exported types -- **Theming** - Customizable theme system - -## Requirements - -> **Important:** HealthKit requires a **custom development build**. It will NOT work in Expo Go. - -- **iOS only** - HealthKit is an Apple-only framework -- **Expo SDK 52+** - Uses native modules -- **Custom dev client** - Requires `npx expo run:ios` or EAS Build -- **Physical device recommended** - Simulator has limited HealthKit support - -## Installation - -```bash -npm install @spezivibe/healthkit @kingstinct/react-native-healthkit react-native-nitro-modules -``` - -### Peer Dependencies - -```json -{ - "react": ">=18.0.0", - "react-native": ">=0.70.0", - "expo": ">=52.0.0", - "expo-constants": ">=17.0.0" -} -``` - -### Expo Configuration - -Add the HealthKit plugin to your `app.config.js` or `app.json`: - -```javascript -// app.config.js -export default { - expo: { - // ... other config - plugins: [ - [ - "@kingstinct/react-native-healthkit", - { - NSHealthShareUsageDescription: "This app needs access to your health data to display your fitness metrics.", - NSHealthUpdateUsageDescription: "This app needs permission to save health data." - } - ] - ] - } -}; -``` - -### Building for iOS - -Since HealthKit requires native code, you must create a custom development build: - -```bash -# Create native iOS project and run on device/simulator -npx expo prebuild --platform ios -npx expo run:ios - -# Or use EAS Build for cloud builds -eas build --platform ios --profile development -``` - -## Quick Start - -### 1. Create Configuration - -Create a configuration file specifying which health data to collect: - -```typescript -// lib/healthkit-config.ts -import { HealthKitConfig, SampleType } from '@spezivibe/healthkit'; - -export const healthKitConfig: HealthKitConfig = { - // Health data to actively collect and display - collect: [ - SampleType.stepCount, - SampleType.heartRate, - SampleType.activeEnergyBurned, - SampleType.sleepAnalysis, - ], - // Read-only access (request permission but don't display by default) - readOnly: [], - // Enable background delivery for specific types - backgroundDelivery: [], - // Optional: sync to backend (requires backend integration) - syncToBackend: false, -}; -``` - -### 2. Add Provider - -Wrap your app (or relevant screens) with `HealthKitProvider`: - -```tsx -// App.tsx or _layout.tsx -import { HealthKitProvider } from '@spezivibe/healthkit'; -import { healthKitConfig } from './lib/healthkit-config'; - -export default function RootLayout() { - return ( - - {/* Your app content */} - - ); -} -``` - -### 3. Display Health Data - -Use the pre-built `HealthView` component: - -```tsx -// app/(tabs)/health.tsx -import { HealthView } from '@spezivibe/healthkit'; -import { healthKitConfig } from '../lib/healthkit-config'; - -export default function HealthScreen() { - return ; -} -``` - -That's it! The `HealthView` component handles authorization prompts, displays configured metrics, and shows a fallback UI if running in Expo Go. - -## Configuration - -### HealthKitConfig - -The configuration object controls which health data your app collects: - -```typescript -interface HealthKitConfig { - /** Health data types to collect and display */ - collect: SampleType[]; - - /** Types with read-only access (permission requested but not displayed) */ - readOnly: SampleType[]; - - /** Types to receive background delivery updates */ - backgroundDelivery: SampleType[]; - - /** Whether to sync health data to backend */ - syncToBackend: boolean; -} -``` - -### Example Configurations - -#### Fitness App - -```typescript -const fitnessConfig: HealthKitConfig = { - collect: [ - SampleType.stepCount, - SampleType.activeEnergyBurned, - SampleType.distanceWalkingRunning, - SampleType.flightsClimbed, - SampleType.workoutType, - ], - readOnly: [], - backgroundDelivery: [SampleType.stepCount], - syncToBackend: false, -}; -``` - -#### Health Monitoring App - -```typescript -const healthMonitorConfig: HealthKitConfig = { - collect: [ - SampleType.heartRate, - SampleType.bloodPressureSystolic, - SampleType.bloodPressureDiastolic, - SampleType.oxygenSaturation, - SampleType.respiratoryRate, - ], - readOnly: [SampleType.bodyMass, SampleType.height], - backgroundDelivery: [SampleType.heartRate], - syncToBackend: true, -}; -``` - -#### Diabetes Management App - -```typescript -const diabetesConfig: HealthKitConfig = { - collect: [ - SampleType.bloodGlucose, - SampleType.insulinDelivery, - SampleType.dietaryCarbohydrates, - ], - readOnly: [], - backgroundDelivery: [SampleType.bloodGlucose], - syncToBackend: true, -}; -``` - -## Components - -### HealthView - -The main dashboard component that displays all configured health metrics. - -```tsx -import { HealthView } from '@spezivibe/healthkit'; - - console.log('Auth:', success)} // Optional -/> -``` - -**Features:** -- Handles HealthKit authorization prompt -- Displays metric cards for all configured types -- Shows Expo Go fallback automatically -- Supports pull-to-refresh - -### MetricCard - -Individual metric display card. - -```tsx -import { MetricCard } from '@spezivibe/healthkit'; -import { SampleType } from '@spezivibe/healthkit'; - - console.log('tapped')} // Optional: tap handler -/> -``` - -### ExpoGoFallback - -Fallback UI shown when running in Expo Go. - -```tsx -import { ExpoGoFallback } from '@spezivibe/healthkit'; - - -``` - -Or provide a custom fallback to `HealthKitProvider`: - -```tsx -} -> - {children} - -``` - -## Hooks - -### useHealthKit - -Main hook for HealthKit operations. - -```tsx -import { useHealthKit } from '@spezivibe/healthkit'; - -function MyComponent() { - const { - isAvailable, // boolean: HealthKit available on this device - isAuthorized, // boolean: user has granted permission - isLoading, // boolean: authorization in progress - error, // Error | null: any error that occurred - requestAuthorization, // () => Promise: request permission - getTodayValue, // (type: SampleType) => Promise - getMostRecent, // (type: SampleType) => Promise - } = useHealthKit(); - - const handlePress = async () => { - const success = await requestAuthorization(); - if (success) { - const steps = await getTodayValue(SampleType.stepCount); - console.log('Today steps:', steps); - } - }; - - if (!isAvailable) { - return HealthKit not available; - } - - return ( -