diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f020f50..2a3edbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,14 +1,8 @@ name: Driver App CI +# Run manually only to avoid consuming GitHub Actions minutes on every PR/push. on: - pull_request: - branches: - - dev - - main - push: - branches: - - dev - - main + workflow_dispatch: concurrency: group: driver-app-ci-${{ github.workflow }}-${{ github.ref }} diff --git a/README.md b/README.md index 52bdc55..019473d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Dedicated native mobile app repository for Clever delivery drivers. ## MVP direction -1. Driver selects a supported country and enters the dispatch-registered phone number in national format. -2. Driver confirms the company/shop/route guidance for the assigned work. +1. Driver selects a supported country, enters the dispatch-registered phone number, and signs in with a six-digit PIN. +2. A first-time driver uses an existing Shopify invitation code once and creates the six-digit PIN; the app does not create or request the invitation. 3. Driver accepts location-information and personal-information consent. -4. Driver views the route assigned for the current delivery day. +4. Driver confirms the company/shop/route guidance and views assigned work. ## Platform direction @@ -19,14 +19,16 @@ Dedicated native mobile app repository for Clever delivery drivers. - `clever-delivery-server` is the source of truth for companies/shops, drivers, routes, orders, assignments, consent records, and location/compliance logs. - This repository owns the driver-facing mobile UX, runtime, local verification, and mobile release evidence. -- Phone number alone must not be treated as a global driver identity; route/company context is part of the access boundary. +- The delivery server owns one global driver account per normalized E.164 phone number. Phone possession alone is not authentication: existing accounts require the PIN, while first registration additionally requires an existing invitation code. +- Shopify invitation/signup creation is administrator- and user-managed. This app does not send or automate that request. ## Current app flow The app includes an interactive route access screen: -- country-aware phone entry with a broad supported-country catalog, locale/language/culture metadata, national display formatting, and E.164 normalization before lookup -- local mock service for delivery-server `POST /driver/route-access/lookup` +- country-aware phone entry with a broad supported-country catalog, national display formatting, and E.164 normalization before account authentication +- phone + six-digit PIN login for existing accounts; first-registration mode adds the existing six-character invitation code and PIN confirmation without collecting a driver name +- separate server-issued account and route access tokens, with account-authenticated `POST /driver/route-access/lookup` - company/shop/route guidance for `INVITED`, plus safe multi-company ambiguity guidance that does not expose route/stop/customer data or driver access tokens - local proof-media upload smoke selector for success, retryable failure, and scanner rejection while the app is using mock services - app-side consent gate for required location-information and personal-information consent @@ -35,9 +37,9 @@ The app includes an interactive route access screen: - assigned route mock/API boundary for delivery-server `GET /driver/assigned-route` - route summary, ordered stop cards, and OS map handoff after consent moves the flow to `route_ready` - explicit delivery start action that requests OS foreground location permission before `delivery_active` -- route started, foreground one-shot location update, continuous background-capable location streaming, native proof photo URI capture, proof media upload references, scanner-rejected proof photo recapture guidance, signature/barcode proof capture, richer stop delivered/failed proof-event mock/API boundaries, and route-completed delivery finish cleanup for delivery-server `POST /driver/events` after delivery_active succeeds +- route started, foreground one-shot location update, continuous background-capable location streaming, native proof photo URI capture, proof media upload references, scanner-rejected proof photo recapture guidance, signature proof capture, richer stop delivered/failed proof-event mock/API boundaries, and route-completed delivery finish cleanup for delivery-server `POST /driver/events` after delivery_active succeeds - safe denial messages for `NOT_FOUND`, `DISABLED`, and `BLOCKED` -- live `EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL` switch for route lookup, native secure storage for the short-lived driver token, live downstream consent/assigned-route/driver-event/proof-media clients with no-store/no-cookie request options, and live `401` recovery that clears the active driver token and returns the driver to country-aware phone lookup; durable app-side offline queue/retry for pending driver events and proof media using AsyncStorage-backed non-secret queue metadata, with app-side discard thresholds for repeated failure, stale age, recorded route completion cleanup, explicit scanner-rejected proof media discard/recapture handling, and an explicit driver session reset action that clears secure access plus queued retry state; delivery-server local/manual proof-media cleanup support and server-side scan rejection hook; production proof-media object storage, signed access, deployed scanner evidence, deployed cleanup evidence, physical-device smoke evidence, and store/privacy disclosure evidence left for follow-up slices +- live `EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL` switch for account authentication and route lookup, native secure storage with account and route credentials kept separate, account refresh plus one route-token retry on downstream `401`, and authoritative removal of cached routes when assignments disappear; durable app-side offline queue/retry for pending driver events and proof media using AsyncStorage-backed non-secret queue metadata, with explicit reset and cleanup behavior See `docs/route-access-flow.md` for the app-side route access, consent, assigned-route, native map handoff, and delivery evidence boundary. @@ -60,7 +62,7 @@ npm install npm run start ``` -Optional live API mode (route lookup saves the returned short-lived driver token through Expo SecureStore, clears expired/invalid persisted tokens before reuse, and clears live downstream `401` tokens before requiring country-aware phone lookup again; see `.env.example`): +Optional live API mode (account login/registration stores server-issued account access and refresh credentials separately from route-scoped driver access; expired route access is reacquired through account refresh and route lookup, while an expired account session returns to phone + PIN login; see `.env.example`): ```bash EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL=https://delivery.example.com npm run start @@ -89,8 +91,7 @@ npx expo install --check git diff --check ``` -GitHub Actions runs the PR/push subset in `.github/workflows/ci.yml` for -`dev` and `main`: dependency install, workspace checks, lint, native release +GitHub Actions provides a manually dispatched verification workflow in `.github/workflows/ci.yml` to avoid consuming minutes on every PR/push: dependency install, workspace checks, lint, native release preflight, non-secret release evidence seed rendering, app bundle export, npm audit, Expo dependency alignment, and diff whitespace checks. CI does not run EAS binary builds and does not replace Apple/Google signing, store/private diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..1ea9b64 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,19 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# generated inline modules +app/src/main/java/inline/ + +# Bundle artifacts +*.jsbundle diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..7e603d7 --- /dev/null +++ b/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('hermes-compiler/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/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.evns.cleverdriverapp' + defaultConfig { + applicationId 'com.evns.cleverdriverapp' + 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/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/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/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/debugOptimized/AndroidManifest.xml b/android/app/src/debugOptimized/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debugOptimized/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ae7f3b8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/evns/cleverdriverapp/MainActivity.kt b/android/app/src/main/java/com/evns/cleverdriverapp/MainActivity.kt new file mode 100644 index 0000000..63a184c --- /dev/null +++ b/android/app/src/main/java/com/evns/cleverdriverapp/MainActivity.kt @@ -0,0 +1,61 @@ +package com.evns.cleverdriverapp + +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); + 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/android/app/src/main/java/com/evns/cleverdriverapp/MainApplication.kt b/android/app/src/main/java/com/evns/cleverdriverapp/MainApplication.kt new file mode 100644 index 0000000..97b53aa --- /dev/null +++ b/android/app/src/main/java/com/evns/cleverdriverapp/MainApplication.kt @@ -0,0 +1,45 @@ +package com.evns.cleverdriverapp + +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.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.common.ReleaseLevel +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ExpoReactHostFactory + +class MainApplication : Application(), ReactApplication { + + override val reactHost: ReactHost by lazy { + ExpoReactHostFactory.getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + } + ) + } + + override fun onCreate() { + super.onCreate() + 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/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..31df827 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..ef243aa Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..e9d5474 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..d61da15 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..4aeed11 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..73a92e2 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..9bdb81c Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..de6b1d4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..3f6a54f Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..0542375 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..c62d628 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..31a1941 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..2cdc46a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8806499 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..7bcde0f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..40a43bb Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..f3f0f5f Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..31179bc Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..56d7513 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..8a65aa4 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..3c05de5 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..74e8d60 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + #FFFFFF + #C7D833 + #023c69 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6f0d189 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Clever Driver + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..b42877d --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..0554dd1 --- /dev/null +++ b/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/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..812d014 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,64 @@ +# 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 + +expo.inlineModules.watchedDirectories=[] +expo.camera.barcode-scanner-enabled=false \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..61285a6 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..4626b90 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..fe17d43 --- /dev/null +++ b/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 = 'Clever Driver' + +expoAutolinking.useExpoVersionCatalog() + +include ':app' +includeBuild(expoAutolinking.reactNativeGradlePlugin) diff --git a/app.json b/app.json index 2a174d0..6262e66 100644 --- a/app.json +++ b/app.json @@ -3,25 +3,34 @@ "name": "Clever Driver", "slug": "clever-driver-app", "scheme": "clever-driver", - "version": "0.1.0", + "version": "1.0.0", "orientation": "portrait", "userInterfaceStyle": "automatic", - "newArchEnabled": true, "ios": { "supportsTablet": false, "bundleIdentifier": "com.evns.cleverdriverapp", - "buildNumber": "1" + "buildNumber": "1", + "icon": "./assets/icon.png" }, "android": { "package": "com.evns.cleverdriverapp", - "edgeToEdgeEnabled": true, - "versionCode": 1 + "versionCode": 1, + "icon": "./assets/icon.png", + "adaptiveIcon": { + "foregroundImage": "./assets/icon.png", + "backgroundColor": "#C7D833" + }, + "softwareKeyboardLayoutMode": "resize", + "blockedPermissions": [ + "android.permission.RECORD_AUDIO" + ] }, "extra": { "projectStartIssue": "EVNSolution/clever-change-control#145", "frameworkBootstrapIssue": "EVNSolution/clever-change-control#146" }, "plugins": [ + "expo-notifications", "expo-secure-store", [ "expo-location", @@ -43,9 +52,14 @@ [ "expo-camera", { - "cameraPermission": "Allow Clever Driver to scan proof barcodes for active deliveries." + "cameraPermission": "Allow Clever Driver to take proof photos for active deliveries.", + "recordAudioAndroid": false, + "barcodeScannerEnabled": false } - ] - ] + ], + "@maplibre/maplibre-react-native", + "expo-status-bar" + ], + "icon": "./assets/icon.png" } } diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..dc732e7 Binary files /dev/null and b/assets/icon.png differ diff --git a/design.md b/design.md index 9dfe5f9..be87fbe 100644 --- a/design.md +++ b/design.md @@ -182,6 +182,12 @@ Gap between grouped sections: The design should feel airy but not sparse. Every screen should be optimized for one-handed mobile use. +Android system navigation clearance: + +- App-owned bottom chrome, bottom sheets, and floating bottom panels must not be hidden behind Android 3-button/system navigation. +- Bottom-attached surfaces should reserve explicit Android bottom clearance in addition to their visual padding. +- This applies to custom sheets and app navigation; native camera/library pickers remain OS-owned. + --- ### 1.5 Corners, Borders, and Shadows @@ -386,7 +392,7 @@ Screens: ```md 1. Login / Driver Verification -2. Today’s Route +2. Current / Upcoming Routes 3. Route Details 4. Live Tracking ``` @@ -569,11 +575,11 @@ Visual: --- -## Screen 2: Today’s Route +## Screen 2: Current / Upcoming Routes ### Purpose -Show the driver one assigned route for the day and allow them to start it. +Show the driver current and upcoming assigned route sessions, nearest first, and allow them to select the active route to start. --- @@ -594,9 +600,9 @@ Status Tabs: Main Content: -- Single assigned route card only +- Current/upcoming assigned route card list - Route summary fields -- Main start action +- Main start action for the selected route Bottom Navigation: @@ -654,12 +660,12 @@ Visual: - Clear selected state ``` -#### Assigned Route Card +#### Assigned Route List / Card ```md Component: -- Large route summary card +- Route summary card list focused on current/upcoming sessions Content Areas: @@ -673,8 +679,10 @@ Content Areas: Behavior: -- Only one route should be displayed -- All field values come from server +- Current and upcoming routes may be displayed when multiple assignments are returned +- Past route history is not shown in this list +- Routes are ordered nearest first by route date/timezone semantics +- All field values come from server or the app mock boundary - Long values should truncate or wrap safely Visual: @@ -739,9 +747,9 @@ Visual: ### Design Notes ```md -- This screen must show exactly one assigned route. -- Do not use multiple route cards. -- The route card should be the visual focus. +- This screen must not imply that only one route or only today routes exist. +- Multiple current/upcoming route cards are allowed when the phone lookup returns multiple assignments. +- The nearest current/upcoming route should be the visual focus. - Keep the primary action obvious. - Avoid adding unnecessary dashboard widgets. ``` @@ -1390,7 +1398,7 @@ Visual: - No icons ``` -#### Today’s Delivery Notes Select +#### Delivery Notes Select ```md Component: @@ -1931,7 +1939,7 @@ Create two separate mockup images. Image 1: - Login / Driver Verification -- Today’s Route +- Current / Upcoming Routes - Route Details - Live Tracking diff --git a/docs/code-organization.md b/docs/code-organization.md index 583cea7..f59861a 100644 --- a/docs/code-organization.md +++ b/docs/code-organization.md @@ -18,7 +18,8 @@ src/ domain/ consent/ # consent state and consent service contract/mock/API behavior delivery/ # delivery start/finish lifecycle decisions - driver/ # access-token store contract and session reset policy + driver/ # separate account/route token store and session reset policy + driverAuth/ # phone/PIN login, invitation registration, and account refresh contract driverFlow/ # driver flow guards and MVP route-tab metadata events/ # driver event recording decisions location/ # foreground/background location event decisions @@ -26,7 +27,7 @@ src/ proof/ # proof capture/upload decisions phone/ # supported-country i18n metadata, national phone formatting, E.164 normalization route/ # assigned-route model and lookup loading decisions - routeAccess/ # phone/context route-access decisions + routeAccess/ # account-bearer route-access decisions stop/ # stop navigation and proof-event decisions platform/ expo/ @@ -91,12 +92,12 @@ Reserved roots from the target architecture remain valid for future additions wh | Events domain | `src/domain/events/driverEvents.ts`, `src/domain/events/driverEvents.test.ts` | | Location domain | `src/domain/location/continuousLocationStream.ts`, `src/domain/location/continuousLocationStream.test.ts`, `src/domain/location/foregroundLocationEvent.ts`, `src/domain/location/foregroundLocationEvent.test.ts` | | Offline domain | `src/domain/offline/offlineSubmissionQueue.ts`, `src/domain/offline/offlineSubmissionQueue.test.ts` | -| Proof domain | `src/domain/proof/proofPhotoCapture.ts`, `src/domain/proof/proofPhotoCapture.test.ts`, `src/domain/proof/proofMediaUpload.ts`, `src/domain/proof/proofMediaUpload.test.ts`, `src/domain/proof/proofBarcodeCapture.ts`, `src/domain/proof/proofBarcodeCapture.test.ts`, `src/domain/proof/proofSignatureCapture.ts`, `src/domain/proof/proofSignatureCapture.test.ts` | +| Proof domain | `src/domain/proof/proofPhotoCapture.ts`, `src/domain/proof/proofPhotoCapture.test.ts`, `src/domain/proof/proofMediaUpload.ts`, `src/domain/proof/proofMediaUpload.test.ts`, `src/domain/proof/proofSignatureCapture.ts`, `src/domain/proof/proofSignatureCapture.test.ts` | | Phone domain | `src/domain/phone/phoneEntry.ts`, `src/domain/phone/phoneEntry.test.ts` | | Route domain | `src/domain/route/assignedRoute.ts`, `src/domain/route/assignedRoute.test.ts` | | Route-access domain | `src/domain/routeAccess/routeAccess.ts`, `src/domain/routeAccess/routeAccess.test.ts` | | Stop domain | `src/domain/stop/stopNavigation.ts`, `src/domain/stop/stopNavigation.test.ts`, `src/domain/stop/stopProofEvents.ts`, `src/domain/stop/stopProofEvents.test.ts` | -| Expo camera adapters | `src/platform/expo/camera/expoProofPhotoCaptureService.ts`, `src/platform/expo/camera/expoProofBarcodeCaptureService.ts` | +| Expo proof photo adapter | `src/platform/expo/camera/expoProofPhotoCaptureService.ts` | | Expo location adapters | `src/platform/expo/location/expoLocationPermissionService.ts`, `src/platform/expo/location/expoForegroundLocationSnapshotService.ts`, `src/platform/expo/location/expoContinuousLocationStreamService.ts` | | Expo storage adapters | `src/platform/expo/secureStore/expoSecureDriverAccessTokenStore.ts`, `src/platform/expo/storage/expoOfflineSubmissionQueueStorage.ts` | | Release evidence | `src/release/evidence/releaseEvidenceSeed.ts`, `src/release/evidence/releaseEvidenceSeedCli.ts`, `src/release/evidence/releaseEvidenceSeed.test.ts`, `src/release/evidence/releaseEvidenceVerifier.ts`, `src/release/evidence/releaseEvidenceVerifierCli.ts`, `src/release/evidence/releaseEvidenceVerifier.test.ts` | diff --git a/docs/driver-app/stop-arrival-fcm-server-request.md b/docs/driver-app/stop-arrival-fcm-server-request.md new file mode 100644 index 0000000..050fccf --- /dev/null +++ b/docs/driver-app/stop-arrival-fcm-server-request.md @@ -0,0 +1,292 @@ +# Stop Arrival FCM Server Request + +## Objective + +Implement server-side FCM stop-arrival alerts for the Clever Driver app. + +When an active driver enters the allowed arrival radius for the **current delivery stop**, the server should send an FCM push notification. Tapping that notification opens the driver app directly to the `Arrival Check` proof screen where the driver can: + +- take / attach a proof photo, +- enter `Delivery Notes`, +- enter `Location Tip`, +- enter `Additional Notes`, +- complete the stop. + +The app-side payload parser and tap routing already exist in PR #116 / branch `driver-static-route-preview`. + +## Current app-side contract already implemented + +### Supported notification data payload + +The app accepts only this data contract: + +```json +{ + "type": "stop_arrival", + "routePlanId": "", + "deliveryStopId": "" +} +``` + +Required fields: + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `type` | string | yes | Must be exactly `stop_arrival`. | +| `routePlanId` | string | yes | Must match the route loaded in the driver app. | +| `deliveryStopId` | string | yes | Must match a stop on that route. | + +The app ignores malformed payloads or payloads for other notification types. + +### App behavior on notification tap + +1. Look up loaded route by `routePlanId`. +2. Look up stop by `deliveryStopId`. +3. If the route is active and the stop is not completed, navigate directly to `Arrival Check`. +4. If the route is not active, navigate to `Route Details` and prompt the driver to start pickup first. +5. If the stop is already completed, navigate to `Route Details` and show completed-state feedback. +6. If route data has not loaded yet, keep the payload pending and retry after assigned routes load. + +Manual `Arrived` remains available as a fallback. + +## Required server-side work + +### 1. Register driver push tokens + +Add a driver-authenticated endpoint to register / refresh native FCM tokens. + +Recommended endpoint: + +```http +PUT /api/driver/mobile/push-token +Authorization: Bearer +Content-Type: application/json +``` + +Recommended request body: + +```json +{ + "platform": "android", + "devicePushToken": "", + "appId": "com.evns.cleverdriverapp", + "appVersion": "0.1.0", + "deviceId": "", + "locale": "en-CA", + "timezone": "America/Vancouver" +} +``` + +Recommended response: + +```json +{ + "ok": true, + "registeredAt": "2026-06-11T08:00:00.000Z" +} +``` + +Notes: + +- Use the same driver access token trust boundary as assigned-route and driver-event APIs. +- Upsert by `(driverId, platform, devicePushToken)` or `(driverId, deviceId)` if a stable device id exists. +- Store `lastSeenAt`, `revokedAt`, `appVersion`, and `platform`. +- Remove / revoke tokens when FCM returns invalid-token / unregistered-token errors. +- A logout unregister endpoint is useful but not strictly required for first delivery if invalid-token cleanup exists. + +Optional unregister endpoint: + +```http +DELETE /api/driver/mobile/push-token +Authorization: Bearer +Content-Type: application/json + +{ + "platform": "android", + "devicePushToken": "" +} +``` + +### 2. Evaluate stop arrival from location updates + +Use the existing driver `LOCATION_UPDATED` stream / event source as the input. + +Server should evaluate only when all conditions are true: + +1. Driver has an active route session. +2. Route has not been completed. +3. Company pickup step is already confirmed / route is in delivery stop sequence. +4. There is a current active delivery stop. +5. Current stop is not completed. +6. Current driver location is within the arrival radius. +7. The stop-arrival notification has not already been sent for this `(driverId, routePlanId, deliveryStopId)`. + +### 3. Arrival radius + +Default radius for proof-entry notification: + +```text +50 meters +``` + +Reason: + +- `120m` is too broad for a proof photo / delivery notes workflow. +- `50m` gives some GPS tolerance while keeping the alert close enough to the real destination. + +Recommended config: + +```ts +STOP_ARRIVAL_RADIUS_METERS = 50 +``` + +Allow this value to become tenant / route / environment configurable later, but use `50m` as the first production default. + +Important distinction: + +- A wider geofence, e.g. `100m~150m`, may be useful later for **pre-arrival guidance**. +- The `stop_arrival` notification should stay tight because it opens proof capture. + +### 4. Distance calculation + +Use haversine distance or the existing geospatial helper. + +Inputs: + +- Driver location: latest location update latitude / longitude. +- Stop location priority: + 1. stop explicit coordinates, + 2. snapped route stop point coordinates, + 3. do not send if no usable coordinates exist. + +Do not send this alert based on text address only. + +### 5. Deduplication / idempotency + +Create durable notification state, for example: + +```text +driver_stop_arrival_notifications +- id +- driver_id +- route_plan_id +- delivery_stop_id +- device_push_token_id nullable +- status: pending | sent | failed | skipped +- distance_meters_at_trigger +- triggered_at +- sent_at nullable +- fcm_message_id nullable +- failure_code nullable +- failure_message nullable +- created_at +- updated_at + +unique(driver_id, route_plan_id, delivery_stop_id) +``` + +Required behavior: + +- Send at most once per stop per driver route session. +- If two location updates cross the radius concurrently, only one notification should be dispatched. +- Do not send for completed stops. +- Do not send after route completion. + +### 6. FCM message shape + +Recommended FCM message: + +```json +{ + "token": "", + "notification": { + "title": "Arrived near Stop ", + "body": "Tap to add proof photo and delivery notes." + }, + "data": { + "type": "stop_arrival", + "routePlanId": "", + "deliveryStopId": "" + }, + "android": { + "priority": "high", + "notification": { + "channelId": "stop-arrivals", + "clickAction": "OPEN_STOP_ARRIVAL" + } + } +} +``` + +Notes: + +- `data` values must be strings for FCM compatibility. +- `channelId` should match app channel intent: `stop-arrivals`. +- Use high priority because this is an active-delivery operational alert. +- If multiple active device tokens exist for a driver, send to all currently valid tokens or the latest active token depending on server device policy. + +### 7. Error handling + +Handle FCM errors explicitly: + +| FCM result | Server action | +| --- | --- | +| success | Mark notification state `sent`, store FCM message id. | +| invalid / unregistered token | Revoke token, mark failed with token error. | +| transient FCM error | Retry with bounded backoff. | +| route / stop no longer active | Mark skipped; do not retry. | + +### 8. Observability + +Add logs / metrics for: + +- location update processed for stop-arrival check, +- entered radius, +- notification deduped, +- notification sent, +- FCM send failed, +- invalid token revoked. + +Avoid logging precise driver location in plaintext beyond existing privacy policy and operational retention rules. + +## Acceptance criteria + +1. Driver starts a route and confirms pickup. +2. Driver location enters `<= 50m` from the current active stop. +3. Server sends exactly one FCM notification with: + +```json +{ + "type": "stop_arrival", + "routePlanId": "...", + "deliveryStopId": "..." +} +``` + +4. Tapping the notification opens the app to `Arrival Check` for that stop. +5. Repeated location updates inside the radius do not send duplicate notifications. +6. Completed stops do not send notifications. +7. Future stops do not send notifications before they become current. +8. If FCM token is invalid, server revokes it and records dispatch failure. + +## App-side local validation path before server work + +Until server FCM dispatch exists, the app schedules a local notification from the active location stream when the current stop enters the same `50m` radius. This exists only to validate UX on a real device without server FCM. + +That local fallback should not be treated as the final production architecture; server-side dispatch is still required for reliable operations visibility and token/device lifecycle management. + +## Files touched on app side + +- `src/domain/notifications/stopArrivalNotifications.ts` + - payload parsing, + - radius candidate logic, + - default `50m` radius. +- `src/platform/expo/notifications/expoStopArrivalNotificationService.ts` + - Expo Notifications channel, + - local notification scheduling, + - notification tap listener, + - native device token read boundary. +- `src/app/AppRoot.tsx` + - notification tap routing to `Arrival Check`, + - pending notification retry after route load, + - active location stream local fallback trigger. diff --git a/docs/phone-verification-plan.md b/docs/phone-verification-plan.md index 0da3003..106b138 100644 --- a/docs/phone-verification-plan.md +++ b/docs/phone-verification-plan.md @@ -2,30 +2,30 @@ ## Purpose -This document records the driver-app phone entry implementation, supported-country i18n metadata, and the remaining SMS verification plan. The current driver app is phone-first, but not yet SMS-verified: it accepts a selected country plus national phone input, normalizes the value to E.164 for route access, shows a verification-code field, and calls route access by phone. The remaining SMS slice should add OTP only through the backend. +This document records the driver-app phone entry implementation and the optional future SMS verification plan. The current app does not use SMS OTP: it normalizes the phone to E.164, signs an existing account in with a six-digit PIN, or registers once with an existing Shopify invitation code plus a new PIN. Shopify invitation/signup creation stays manual and is not requested by the app or server login flow. Any future SMS slice must be backend-owned. ## Current app state -- `src/app/AppRoot.tsx` owns the current login screen state: selected phone country, national phone input, `verificationCode`, `driverName`, privacy consent, and location consent. -- `LoginScreen` currently renders a country selector/search panel, localized country/language label, culture metadata row, calling-code prefix, national `Phone Number` input, E.164 preview, and one `Verification Code` input. `Send Code` is visible in the UI, but the current phone lookup path does not depend on SMS verification. -- `handleLoginAndLoadRoutes()` normalizes selected country + national phone input and calls `submitRouteAccess({ phoneE164 }, routeAccessService)` after driver name and consent checks. +- `src/app/AppRoot.tsx` owns selected country, national phone input, six-digit PIN, optional first-registration invitation code/PIN confirmation, and required consent state. It does not collect a driver name. +- The first page contains country + phone. The second page defaults to existing-account PIN login and can switch to first registration, which adds the existing invitation code and PIN confirmation. +- Existing accounts call `POST /driver/auth/login`; first registration calls `POST /driver/auth/verify-invite`; both return a server-owned account session before route lookup. - `src/domain/phone/phoneEntry.ts` owns the supported country catalog, localized country/language labels, culture metadata, search, and national formatting/E.164 normalization; `src/domain/driverFlow/driverFlow.ts` still validates the E.164 boundary with `E164_PHONE_PATTERN = /^\+[1-9]\d{7,14}$/`. -- `src/domain/routeAccess/routeAccess.ts` sends `phoneE164` and `routeContext: null` to `POST /driver/route-access/lookup` in live mode. -- Successful route lookup returns route choices, company guidance, and a short-lived driver bearer token. SMS must not be implemented in the app with provider credentials. +- `src/domain/routeAccess/routeAccess.ts` sends the account bearer plus `routeContext: null` to `POST /driver/route-access/lookup`; it does not resend the phone. +- Successful route lookup returns route choices, company guidance, and route-scoped driver tokens. SMS provider credentials must never be implemented in the app. ## Implementation status Implemented in the driver app for the phone-entry foundation: - `src/domain/phone/phoneEntry.ts` owns a broad supported-country catalog of 70+ driver markets, country calling codes derived from `libphonenumber-js` metadata, localized country/language labels, native country/language names, locale, text direction, week-start, measurement-system metadata, search by country/native name/ISO/calling code/locale/language, national formatting, and E.164 normalization. -- `src/app/AppRoot.tsx` renders the selected country row, localized country/language label, culture metadata row, searchable country panel, calling-code prefix, national phone input, and E.164 preview. Selecting a country updates the phone-entry locale to that country's default locale. -- Existing route access still receives only `phoneE164`; SMS sending remains server-integration-ready but not active in the app. +- `src/app/AppRoot.tsx` renders the selected country row, searchable country panel, calling-code prefix, national phone input, PIN login, and first-registration invitation/PIN controls. +- Server-owned phone/PIN account authentication and account-bearer route lookup are implemented. SMS sending is not active. Still pending from this plan: - Server-owned OTP start/verify endpoints. - SMS provider selection, budget controls, fraud/rate limits, and production pricing recheck. -- Native OTP autofill hardening beyond the current verification-code field. +- Native OTP entry/autofill UI if the server-owned OTP path is approved later. ## Target UX @@ -64,7 +64,7 @@ Supported countries are still explicit app data rather than unrestricted global- Split phone entry into two values: - `nationalPhoneInput`: what the driver types and sees. -- `phoneE164`: normalized identity sent to route access and SMS verification APIs. +- `phoneE164`: normalized identity sent to account authentication and any future SMS verification APIs. Example for South Korea: @@ -83,7 +83,7 @@ Example for Canada: Implementation recommendation: - Use a maintained phone-number library with country metadata, such as `libphonenumber-js`, for parsing, country-specific formatting, and E.164 normalization. -- Keep the current app/server contract as E.164. The UI may format per country, but API payloads should continue to send `phoneE164`. +- Keep the current app/server contract as E.164. The UI may format per country, while account-auth payloads send the normalized phone and route lookup uses the resulting account bearer. - Do not rely on manual regex per country except as a fallback or smoke-test fixture. Country numbering plans change, and trunk-prefix rules differ by country. Planned domain boundary: @@ -107,7 +107,7 @@ type PhoneEntryNormalizationResult = }; ``` -Validation should happen before enabling `Send Code` or `Continue`. Error copy should avoid technical E.164 wording; use driver-facing text such as `Enter a valid mobile phone number for the selected country.` +Validation happens before `Continue`. Error copy avoids technical E.164 wording; use driver-facing text such as `Enter a valid mobile phone number for the selected country.` ### 3. SMS verification and OTP autofill @@ -239,8 +239,9 @@ Recommendation: do not use raw SMS for the first production OTP slice. Start wit - Add country selector/search UI. — completed - Add phone normalization domain module and tests. — completed - Expand supported-country i18n metadata and representative national phone formatting coverage across driver markets. — completed -- Convert national input + country to E.164 before calling existing route access. — completed -- Keep current phone-only route lookup behavior. — completed +- Convert national input + country to E.164 before account authentication. — completed +- Replace phone-only route lookup with phone + PIN account login and account-bearer route lookup. — completed +- Keep first registration invitation-based and Shopify invitation creation manual. — completed - Add phone-domain tests for KR, CA, and representative global formatting examples. — completed ### Phase 2: Server-owned OTP verification diff --git a/docs/physical-device-smoke-runbook.md b/docs/physical-device-smoke-runbook.md index 9626625..9a24f0d 100644 --- a/docs/physical-device-smoke-runbook.md +++ b/docs/physical-device-smoke-runbook.md @@ -65,24 +65,26 @@ Examples: ## Smoke sequence Run the full sequence once on a real iPhone and once on a real Android phone. -Use synthetic route, stop, proof, barcode, and signature data unless production validation is explicitly approved. +Use synthetic route, stop, proof, and signature data unless production validation is explicitly approved. | Step | Expected evidence | Stop condition | | --- | --- | --- | | Fresh install and launch | App opens with version/build identifier recorded. | App cannot launch or crashes before route lookup. | -| E.164 phone lookup | Company guidance appears before any stop/customer data. | Route data appears before company guidance or consent. | +| E.164 phone + PIN login | Existing account signs in without another invitation code; no company/route data appears before account authentication. | Phone alone reveals company, route, stop, or customer data. | +| First invitation registration | An administrator-created invitation code plus matching six-digit PIN/confirmation creates the account without asking for a driver name. | The app attempts to create/request a Shopify invitation, persists the PIN/invitation, or accepts mismatched PINs. | | Multi-company guidance | Shop/company name, route name/date, timezone, pickup guidance, and support contact match the test assignment. | Wrong tenant/company guidance appears. | | Consent gate | Required location-information and personal-information consent can be recorded; failure/retry state is visible if simulated. | Assigned route appears before consent success. | | Assigned route and stop list | Route summary and ordered stop cards match synthetic route data. | Wrong route, wrong date/timezone, or another driver's stop appears. | | Stop-card OS map handoff | `Open map` launches the native map handler from coordinates; address fallback works for a stop without coordinates. | Map opens the wrong destination or no fallback exists. | | Delivery start foreground location | OS foreground location prompt appears only after explicit delivery start; denial keeps delivery out of `delivery_active`. | Location prompt appears before delivery start or denial still activates delivery. | | Continuous/background-capable tracking | Background permission prompt and foreground service/background indicator behavior match the platform; `LOCATION_UPDATED` events record or queue. | Tracking starts before active delivery or cannot be stopped. | -| Proof capture | Camera/library photo, signature drawing, and barcode scan success/denial/unavailable states are visible. | Proof controls are available before active delivery or failed capture becomes durable proof. | +| Proof capture | Camera/library photo and signature drawing success/denial/unavailable states are visible. | Proof controls are available before active delivery or failed capture becomes durable proof. | | Proof media scan rejection | In local mock mode, set `Local proof media upload mock` to `scan_rejected`; in live mode, use a server `PROOF_MEDIA_REJECTED` upload response. The app shows recapture guidance without queuing that photo as retryable proof. | Rejected proof media becomes durable evidence or remains in the retry queue. | | Offline retry/discard | Network loss queues driver events/proof media; retry syncs or discards according to policy; app restart hydrates queue count. | Queue loses pending evidence unexpectedly or stores driver access tokens. | | Delivery finish cleanup | Finish stops continuous tracking, records or queues `ROUTE_COMPLETED`, and only clears route-scoped queue items after recorded completion. | Tracking continues after finish or queued completion evidence is discarded after failed record. | | Driver session reset | Reset stops tracking, clears SecureStore driver access, clears queued retry state, blanks lookup inputs, and returns to safe lookup state. | Secure token or queued retry state remains after reset. | -| Token expiry/invalid token recovery | Expired or malformed persisted token is cleared before reuse; live downstream `401` from consent, assigned route, event, proof media, or offline retry clears the active token, stops/clears active route UI state, and requires phone lookup again. | Expired token can still access downstream route/event/proof APIs or retry loops without re-lookup guidance. | +| Token expiry/invalid token recovery | Expired route access triggers account refresh plus route lookup and one retry; expired account refresh clears authentication and requires phone + PIN login. | Account and route tokens are confused, or expired credentials continue to access downstream APIs. | +| Deleted/unassigned route refresh | Removing an assignment server-side makes the route disappear after refresh while the account remains signed in. | Deleted routes remain actionable or a transient network failure incorrectly signs the account out. | ## Evidence notes diff --git a/docs/project-brief.md b/docs/project-brief.md index 67b3bc3..0989810 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -22,7 +22,7 @@ agent 작업 절차, branch 운영, 테스트 순서, 완료 조건은 `AGENTS.m 이 repo가 책임지는 것: - 배송원 모바일 UX와 앱 런타임 코드 -- 전화번호 기반 접근 시작 화면, 회사 안내, 초대/접근 상태 표시 +- 전화번호 + 6자리 PIN 기반 계정 로그인, 최초 초대 등록, 회사 안내 - 위치정보 처리 동의 및 개인정보 이용 동의 UX - 배송원에게 배정된 당일 route/stop 조회 화면 - 모바일 앱의 로컬 검증, 빌드, smoke evidence @@ -47,7 +47,7 @@ Clever/Tomatono 배송 운영에는 관리자 콘솔과 delivery server는 준 ## 기대 결과 -배송원이 전화번호 기반으로 접근하고, 자신이 수행할 회사/shop/route 안내를 확인하고, 위치정보 및 개인정보 이용 동의를 완료한 뒤, 당일 자신에게 배정된 route를 확인해 배송을 준비할 수 있는 1차 MVP 앱을 만든다. +배송원이 전화번호 + 6자리 PIN으로 로그인하고, 자신이 수행할 회사/shop/route 안내를 확인하고, 위치정보 및 개인정보 이용 동의를 완료한 뒤, 당일 자신에게 배정된 route를 확인해 배송을 준비할 수 있는 1차 MVP 앱을 만든다. ## 플랫폼 전략 @@ -70,7 +70,7 @@ Clever/Tomatono 배송 운영에는 관리자 콘솔과 delivery server는 준 MVP와 확장 경계: -- route 조회 MVP는 전화번호 접근, 동의 기록, 당일 assigned route 확인까지를 최소 기능으로 둔다. +- route 조회 MVP는 전화번호 + PIN 계정 접근, 동의 기록, 당일 assigned route 확인까지를 최소 기능으로 둔다. - foreground/background location service는 앱 플랫폼 선택의 핵심 요구사항이지만, 실제 위치 이벤트 송신은 별도 구현 이슈에서 `delivery_active` slice로 분리할 수 있다. - 후속 이슈가 scope를 확장하지 않는 한 framework bootstrap PR은 background location을 실제 수집하지 않고 권한/설정 위치와 테스트 가능성만 준비한다. @@ -85,7 +85,7 @@ MVP와 확장 경계: - 서버가 route/order/driver canonical source of truth다. - 모바일 앱은 배송원용 UX에 집중하고 관리자 기능을 포함하지 않는다. -- 다중회사/다중 shop 구조를 전제로 하며, 전화번호 하나가 전역 driver identity라고 가정하지 않는다. +- 다중회사/다중 shop 구조를 전제로 하되, 정규화된 E.164 전화번호 하나를 서버 소유의 전역 driver account identity로 사용한다. - 위치정보 동의, 개인정보 이용 동의, 접근 로그/이용 기록 요구사항은 delivery server의 compliance 계획과 호응해야 한다. - 구현 작업은 target issue와 linked branch를 통해 진행한다. - 본 bootstrap 범위는 repo 준비와 플랫폼 방향 고정까지이며 앱 프레임워크/인증 상세 구현은 후속 이슈에서 결정한다. @@ -94,7 +94,7 @@ MVP와 확장 경계: ### 포함 -- 배송원 전화번호 입력 기반 접근 시작 UX +- 배송원 전화번호 + 6자리 PIN 로그인 및 최초 초대 등록 UX - 배송원이 자신이 수행할 회사/shop/route 안내를 확인하는 UX - 위치정보 처리 동의 및 개인정보 이용 동의 UX/기록 연동 - 당일 배정 route 조회와 배송 준비 화면 @@ -114,7 +114,7 @@ MVP와 확장 경계: ## 기능 초안 -1. 전화번호 입력 및 서버 driver route assignment 상태 확인 +1. 국가번호 + 전화번호 입력 후 기존 계정은 6자리 PIN 로그인, 최초 계정은 기존 Shopify 초대코드 + 6자리 PIN 설정 2. 배정된 라우트 리스트 표시; 각 라우트에 회사/shop 안내를 귀속해서 표시 3. 위치정보/개인정보 동의 수집 및 서버 기록 연동 4. 당일 assigned route/stop list 조회 @@ -125,32 +125,38 @@ MVP와 확장 경계: `회사`는 배송원이 인지하는 운영 주체이고, 서버 데이터 경계에서는 Shopify `Shop` 또는 tenant에 해당한다. -- 같은 전화번호가 여러 회사/shop의 driver invite 또는 route assignment에 등장할 수 있다. -- 기본 접근 단위는 서버에 등록된 배송원 전화번호다. -- 전화번호가 서버에 존재하면 접근은 가능하며, 서버는 해당 전화번호의 활성 라우트 선택지를 반환한다. 활성 라우트가 없으면 빈 라우트 리스트를 반환한다. +- 같은 전역 전화번호 계정에 여러 회사/shop의 driver invite 또는 route assignment가 연결될 수 있다. +- 기본 접근 단위는 서버가 관리하는 E.164 전화번호 계정이며 PIN은 이 전화번호 전체에 귀속된다. +- 계정 인증에 성공하면 서버는 해당 전화번호에 연결된 활성 라우트 선택지를 반환한다. 활성 라우트가 없으면 빈 라우트 리스트를 반환한다. - 배송원 입장에서는 다중회사도 “여러 라우트”일 뿐이며, 회사/shop 정보는 각 라우트 정보에 귀속된다. - 회사 안내에는 회사/shop 표시명, route 이름 또는 배송일, shop/route timezone 기준 `deliveryDate`, 출발/픽업/집결 안내, 운영자 연락처, 회사별 배송 유의사항, consent/legal copy source가 포함될 수 있다. -- 전화번호 확인 전에는 route/stop/customer data를 노출하지 않는다. +- 전화번호 + PIN 계정 인증 전에는 route/stop/customer data를 노출하지 않는다. - 서버가 반환한 route choice 전에는 다른 회사의 route/stop/customer data를 보여주지 않는다. ## 핵심 사용자 시나리오 -### 시나리오 1: 앱 첫 실행과 전화번호 접근 +### 시나리오 1: 앱 첫 실행과 전화번호/PIN 접근 - 배송원이 iPhone 또는 Android phone에서 앱을 실행한다. - 앱은 관리자 기능 없이 배송원용 시작 화면을 보여준다. - 배송원은 지원 국가를 선택하고, 앱은 국가명/ISO/국가번호와 언어/locale/culture metadata를 표시하며, 해당 국가의 national phone format으로 전화번호를 입력한다. -- 앱은 전화번호를 E.164로 정규화한 뒤 서버에 phone-only lookup을 요청해 해당 전화번호에 활성 라우트가 배정되어 있는지 확인한다. +- 앱은 전화번호를 E.164로 정규화한 뒤 두 번째 화면에서 6자리 숫자 PIN을 입력받는다. +- 기존 계정은 `전화번호 + PIN`으로 로그인하며 초대코드를 다시 요구하지 않는다. +- 최초 계정 등록 모드에서만 이미 Shopify 관리자가 발급한 6자리 초대코드와 새 PIN/확인 PIN을 받는다. +- 앱과 delivery server는 로그인 또는 등록 시 Shopify 초대 생성 요청을 자동으로 만들지 않는다. Shopify에서의 배송원 초대/가입 요청은 store 관리자가 별도로 직접 수행한다. +- 계정 로그인/등록 성공 후 account bearer로 route lookup을 요청해 해당 전화번호에 활성 라우트가 배정되어 있는지 확인한다. - 배정된 활성 라우트가 있으면 앱은 route list를 보여주며, 각 route card에 회사 안내를 함께 표시한다. - 초대되지 않은 번호, 비활성 driver, 차단된 driver는 route 데이터를 받지 못하고 안내 화면에 머문다. -- MVP 문서 기준 첫 관문은 `전화번호 + 서버 route assignment 확인`으로 둔다. -- 서버는 phone lookup 성공 시 route-scoped short-lived driver access token을 함께 내려주고, 앱은 그 token으로 consent/assigned-route/event/proof-media API를 호출한다. -- 현재 앱은 short-lived token의 expiry/invalid payload와 live downstream `401`을 안전하게 처리해 secure token과 active route UI state를 지우고 phone lookup을 다시 요구한다. -- OTP, deep link invite token, managed identity 같은 강한 인증과 서버-issued refresh는 driver API contract 후속 이슈에서 결정한다. +- 첫 관문은 `전화번호 + 6자리 PIN 계정 인증`으로 둔다. +- 서버는 로그인/등록 성공 시 전역 `driver_account` access/refresh token을 내려주고, account bearer route lookup 성공 시 라우트별 short-lived `consent_and_assigned_route` token을 내려준다. +- 앱은 account token과 route token을 secure storage에서 분리하고, account refresh 후 route lookup을 다시 수행해 만료된 route token을 교체한다. +- 서버에서 배정이 삭제되거나 route lookup이 빈 결과/`NOT_FOUND`를 반환하면 account login은 유지하되 route token, route context, active route session, 화면 route list를 제거한다. 네트워크 오류만으로는 기존 route cache를 삭제하지 않는다. +- SMS OTP는 장기적으로 정상 회원가입 경로가 될 수 있지만 이번 버전에는 포함하지 않는다. +- 이 인증 구조 배포 전에 기존 배송원 레코드는 운영 DB에서 삭제했으며, 구 앱 secure-storage schema는 마이그레이션하지 않고 재로그인시킨다. ### 시나리오 1-a: 회사 안내 확인 -- 앱은 route/phone lookup 성공 후 배송원이 어느 회사/shop의 어떤 배송 흐름에 들어왔는지 명확히 표시한다. +- 앱은 계정 인증과 route lookup 성공 후 배송원이 어느 회사/shop의 어떤 배송 흐름에 들어왔는지 명확히 표시한다. - 최소 안내 정보는 company/shop 표시명, deliveryDate, route 이름 또는 route summary, 출발/픽업/집결 안내, 운영자 연락처다. - 회사별 배송 유의사항이나 consent/legal copy source가 있으면 동의 gate 이전 또는 동의 화면에서 함께 보여준다. - 배송원이 회사/route가 본인 업무와 다르다고 판단하면 route 화면으로 진행하지 않고 종료 또는 지원 문의로 이동할 수 있어야 한다. @@ -179,7 +185,7 @@ MVP와 확장 경계: - 위치 수집과 위치 이벤트 송신은 `배송 시작` 이후에만 허용한다. - 배송 시작 이벤트는 서버 driver event API에 `ROUTE_STARTED`로 기록한다. foreground one-shot GPS 위치 업데이트와 continuous/background-capable GPS update는 `LOCATION_UPDATED`로 전송할 수 있다. - `delivery_active` 이후 continuous tracking action은 background location permission과 native task availability를 확인한 뒤 named task를 시작하고, task batch를 서버 driver event API의 `LOCATION_UPDATED`로 기록한다. -- `delivery_active` 이후 stop card에서 배송 완료/실패를 누르면 앱은 서버 driver event API에 `STOP_DELIVERED` 또는 `STOP_FAILED`를 기록한다. 현재 proof는 note, failure reason, Expo ImagePicker 기반 photo capture, proof media upload reference, scanner-rejected photo recapture guidance, signature drawing evidence, barcode scan evidence와 app-side offline queue/retry를 포함한다. Delivery server에는 proof-media scan rejection hook이 있으며, production object storage/signed access/deployed scanner evidence는 후속 slice에서 다룬다. +- `delivery_active` 이후 stop card에서 배송 완료/실패를 누르면 앱은 서버 driver event API에 `STOP_DELIVERED` 또는 `STOP_FAILED`를 기록한다. 현재 proof는 note, failure reason, Expo ImagePicker 기반 photo capture, proof media upload reference, scanner-rejected photo recapture guidance, signature drawing evidence와 app-side offline queue/retry를 포함한다. Delivery server에는 proof-media scan rejection hook이 있으며, production object storage/signed access/deployed scanner evidence는 후속 slice에서 다룬다. - 서버 compliance 기준상 driver GPS `LOCATION_UPDATED`는 위치정보 `COLLECT` 성격의 동작으로 본다. - 배송 시작 전에는 background location 수집을 하지 않는다. @@ -198,8 +204,8 @@ MVP와 확장 경계: ```text unidentified -> phone_entered + -> account_authenticated -> company_context_confirmed - -> invited -> consent_required -> consent_recorded -> route_ready @@ -208,9 +214,9 @@ unidentified ``` - `unidentified`: 앱 첫 실행 또는 session 없음. 전화번호 입력 전 상태. -- `phone_entered`: 서버에 등록된 배송원 전화번호가 입력된 상태. -- `company_context_confirmed`: phone lookup이 하나 이상의 회사/shop route assignment와 매칭되어 라우트별 회사 안내를 표시할 수 있는 상태. -- `invited`: 서버가 초대된 배송원으로 확인했지만 필수 동의가 끝나지 않은 상태. +- `phone_entered`: E.164 배송원 전화번호가 입력된 상태. +- `account_authenticated`: 기존 계정 PIN 로그인 또는 최초 초대 등록을 마쳐 `driver_account` session이 발급된 상태. +- `company_context_confirmed`: account-authenticated route lookup이 하나 이상의 회사/shop route assignment와 매칭되어 라우트별 회사 안내를 표시할 수 있는 상태. - `consent_required`: 위치정보/개인정보 동의가 필요하거나 consent version이 갱신된 상태. - `consent_recorded`: 서버가 필수 동의를 기록했고 route 조회가 가능한 상태. - `route_ready`: 당일 assigned route를 확인할 수 있으나 아직 위치 수집은 시작하지 않은 상태. @@ -227,8 +233,8 @@ unidentified ## 실패/예외 시나리오 -- 초대되지 않은 전화번호: 가입/관리자 문의 안내를 보여주고 route/consent API로 진행하지 않는다. -- 전화번호 미등록: 회사/route data를 노출하지 않고 운영자 문의를 안내한다. 등록된 번호이지만 활성 라우트가 없으면 빈 라우트 리스트를 보여준다. +- 초대되지 않은 전화번호의 최초 등록: 가입/관리자 문의 안내를 보여주고 route/consent API로 진행하지 않는다. 앱은 Shopify 초대를 자동 요청하지 않는다. +- 전화번호 미등록 또는 PIN 불일치: 회사/route data를 노출하지 않고 로그인 오류를 표시한다. 등록된 계정이지만 활성 라우트가 없으면 빈 라우트 리스트를 보여준다. - 같은 전화번호의 다중 회사/route 매칭: 서버가 허용한 route choices를 보여주며, 각 choice에 회사 안내와 route metadata를 귀속한다. - 비활성 또는 차단된 driver: 접근 불가 안내를 보여주고 session을 만들지 않는다. - service/legal consent 철회 또는 consent version 갱신: `consent_required`로 되돌리고 route 화면 진입을 막는다. @@ -239,15 +245,16 @@ unidentified - 서버/API 장애: 현재 화면의 민감 데이터 확대 표시를 피하고 재시도 가능한 오류 상태로 둔다. - 네트워크 불안정: driver event와 proof media submission은 durable app-side offline queue/retry 대상으로 관리하되, 중복 전송과 민감 payload logging을 피한다. 앱 로컬 queue는 5회 실패, 72시간 경과, route completion cleanup, 또는 명시적 driver session reset/sign-out 시 discard할 수 있는 기준과 앱 UI action을 둔다. - proof media scanner rejection: 서버가 `422 PROOF_MEDIA_REJECTED`를 반환하면 앱은 durable proof reference를 만들지 않고 해당 사진을 retry queue에 남기지 않는다. 배송원에게는 scanner 내부 사유를 노출하지 않고 다른 proof photo를 다시 촬영하라고 안내한다. -- live downstream 인증 만료: consent, assigned-route, driver-event, proof-media upload, offline retry에서 `401`이 오면 secure driver token과 active route UI state를 제거하고 phone lookup부터 다시 진행하도록 안내한다. retry 가능한 event/proof item은 token 없이 local queue에 남긴다. +- live downstream route token 만료: account session을 refresh한 뒤 account bearer route lookup으로 새 route token을 받고 consent, assigned-route, driver-event, proof-media 요청을 한 번 재시도한다. account refresh까지 실패하면 계정 로그인을 지우고 전화번호 + PIN 로그인을 요구한다. ## Server contract 필요 항목 -후속 server/API 이슈에서 아래 driver-facing contract를 정의해야 한다. +delivery server와 앱은 아래 driver-facing contract를 사용한다. -- phone lookup: 전화번호를 기준으로 tenant/company, route assignment, not-found/disabled/blocked 상태를 구분하고, 활성 배정 라우트 선택지를 반환한다. +- driver account auth: E.164 전화번호 전역 계정에 6자리 PIN hash와 refresh session을 귀속하고, 기존 계정 login 및 최초 invite registration을 분리한다. +- account-authenticated route lookup: `driver_account` bearer의 전화번호를 기준으로 tenant/company, route assignment, not-found/disabled/blocked 상태를 구분하고 활성 배정 라우트 선택지를 반환한다. request body에 전화번호를 다시 받지 않는다. - company guidance payload: company/shop display name, deliveryDate, timezone, route display name/summary, pickup/depot/dispatch guidance, operator support contact, company-specific driver instructions를 민감 data 없이 반환한다. -- driver session/access boundary: phone lookup 성공 후 route/stop read에 사용할 tenant-scoped and driver-scoped short-lived session/access token 또는 동등한 서버 검증 경계를 정의한다. +- driver session/access boundary: account access/refresh token과 route-scoped short-lived access token의 용도와 저장 경계를 분리한다. - consent record: consent type, consent version, driver identity, timestamp, device/app context를 서버에 기록한다. - assigned route read: shop/shopDomain tenant boundary와 assigned driver boundary 안에서만 shop/route timezone 기준 당일 route summary와 stop list를 반환한다. - stop detail read: 배송 준비에 필요한 주소/순서/지도 이동 정보를 반환하되 다른 driver route 접근은 차단한다. @@ -287,14 +294,14 @@ unidentified - 완료 기준: - clean checkout에서 `npm install` 후 앱 시작 command가 준비된다. - PR 전 필수 검증 명령이 repo 현실에 맞게 정의되고 통과한다. - - phone lookup, route choices, consent gate, delivery active guard가 unit test로 고정된다. + - account auth, account-authenticated route choices, consent gate, delivery active guard가 unit test로 고정된다. -### 2단계: phone access and route/company guidance UX +### 2단계: phone/PIN account access and route/company guidance UX -- 목적: 배송원이 전화번호를 입력하고 서버에서 활성 route assignment와 driver access 상태를 확인하는 시작 흐름을 만든다. +- 목적: 배송원이 전화번호와 PIN으로 계정을 인증하고 서버에서 활성 route assignment와 driver access 상태를 확인하는 시작 흐름을 만든다. - 선행 계약: - supported-country i18n metadata, country-aware national phone formatting, and E.164 normalization 기준 - - delivery server의 driver-facing phone lookup endpoint + - delivery server의 driver account login/register/refresh 및 account-authenticated route lookup endpoint - `ROUTES_FOUND` route choice payload shape - route-scoped driver access token payload shape - not-found/disabled/blocked/error 상태 코드 @@ -305,7 +312,7 @@ unidentified - session/access state 저장 방식 - 완료 기준: - 유효하지 않은 번호는 서버 요청 전에 막는다. - - phone lookup 성공 시 서버가 허용한 route choices만 표시한다. + - account auth와 route lookup 성공 시 서버가 허용한 route choices만 표시한다. - 매칭된 배송원은 라우트별 회사/shop/route 안내를 확인한 뒤 consent/route 단계로 이동한다. - 서버가 route-scoped access token을 준 라우트만 consent/assigned-route API로 진행한다. @@ -315,7 +322,7 @@ unidentified - 선행 계약: - legal copy source - consent versioning - - delivery server consent record endpoint: app API boundary is implemented; live mode uses the phone lookup `driverAccess` token for consent submission + - delivery server consent record endpoint: live mode uses route lookup이 반환한 route-scoped `driverAccess` token for consent submission - foreground/background location 권한 요청 시점과 거절/재요청 UX - 산출물: - consent screen: implemented in local Expo flow @@ -324,23 +331,23 @@ unidentified - 완료 기준: - 동의 전에는 route 화면에 접근할 수 없다. - 동의 성공 후 서버 기록 결과를 근거로 `consent_recorded` 상태로 이동한다. - - live server consent submission still requires phone lookup 후속 driver access token/session issuance. + - live server consent submission은 선택 라우트의 route-scoped driver access token으로 수행된다. ### 4단계: assigned route MVP -- 목적: 배송원이 당일 자신에게 배정된 route와 stop list를 확인한다. +- 목적: 배송원이 현재/예정 route session과 stop list를 확인한다. - 선행 계약: - assigned route 조회 endpoint: delivery-server `GET /driver/assigned-route` and app API boundary are implemented - route/stop response shape: route summary and ordered stop cards are implemented in the local Expo flow - no-route, multiple-route, API error 상태 처리 기준: no-route and API error states are implemented; multi-company/multiple-route assignments are now app-visible route choices with company guidance attached to each route - 산출물: - - today's route screen: implemented after consent with local mock/API boundary + - current/upcoming routes screen: implemented after consent with local mock/API boundary - stop list and stop detail screen: implemented as ordered stop cards for route-ready MVP - 주소/순서/지도 이동 준비 정보 표시: address, sequence, phone, coordinate text, and OS map handoff are implemented; provider SDK selection remains a later product decision - 완료 기준: - - invited phone → consent accepted → today's route 확인 smoke flow가 가능하다. + - phone + PIN login 또는 invite registration → consent accepted → current/upcoming route 확인 smoke flow가 가능하다. - route 없음/error 상태가 사용자에게 명확히 표시된다. - - live server calls can use phone lookup `driverAccess` token handoff through native secure storage with expiry/invalid/live `401` clearing and phone re-lookup recovery; token refresh/strong re-auth remains later server/session work. + - live server calls는 secure storage의 account token과 route token을 분리하며, route token `401` 시 account refresh + route re-lookup으로 복구한다. ### 5단계: release evidence and context sync @@ -362,21 +369,21 @@ unidentified ## 데이터와 연동 -- input data: selected phone country, national phone input normalized to E.164, consent decisions, current date/device context, server-issued route assignment identifiers +- input data: selected phone country, national phone input normalized to E.164, 6-digit PIN, first-registration invite code, consent decisions, current date/device context, server-issued route assignment identifiers - output data: company guidance, consent record, assigned route/stop display state, driver session/access state, optional location update after MVP expansion - external systems: `clever-delivery-server`, Tomatono Shopify order context, mobile map/provider stack -- public contract: delivery server route access lookup, consent record, assigned route read, route-started driver event, foreground and continuous/background-capable `LOCATION_UPDATED` events, richer `STOP_DELIVERED`/`STOP_FAILED` proof metadata events, and `ROUTE_COMPLETED` delivery finish event with native photo URI capture, proof media upload references, scanner-rejected proof media handling, signature drawing evidence, barcode scan evidence, and durable app-side offline queue/retry are implemented as app-side boundaries; short-lived driver access tokens are persisted in native secure storage and cleared on expiry/invalid payloads or live downstream `401`, which returns the driver to phone lookup; app-side offline queue retention/discard thresholds are implemented for repeated failure, stale age, recorded route cleanup, scanner rejection, and session reset; delivery server proof-media scan rejection hook and local/manual cleanup runner exist; token refresh/strong re-auth, production proof-media object storage/signed access/deployed scanner evidence, deployed cleanup evidence, and physical-device background smoke evidence remain follow-up work +- public contract: delivery server driver account login/register/refresh, account-authenticated route access lookup, consent record, assigned route read, driver events, location updates, proof media, and route completion boundaries are implemented. Account and route tokens are persisted separately; authoritative empty/deleted assignment results clear only route cache, while expired account refresh requires phone + PIN login. ## 검증 초안 - local verification: lint, typecheck, unit tests, app start/build command after framework bootstrap -- automated tests: phone input validation, route choice parsing, company guidance rendering, consent gate, assigned route rendering, API error states -- smoke test: open app, enter invited phone, confirm company guidance, accept consents, see today's route +- automated tests: phone/PIN auth contract, first-registration invite contract, account-bearer route lookup, token separation/refresh, deleted assignment cache clearing, route choice parsing, consent gate, assigned route rendering, API error states +- smoke test: open app, enter phone + PIN or complete first registration, accept consents, see current/upcoming route choices, remove assignment server-side, refresh and confirm it disappears - release evidence: linked PR, CI output, mobile build artifact or local runtime screenshot/video ## 미정 사항 -- driver authentication/session method after phone lookup +- SMS OTP registration/recovery path and forgotten-PIN policy - consent legal copy source and production version registry - dedicated native map provider SDK choice and background location policy for post-MVP - minimum supported iOS/Android versions and physical-device background-location smoke matrix @@ -399,7 +406,7 @@ unidentified 13. Implement background location service and continuous GPS `LOCATION_UPDATED` streaming. — completed 14. Add richer proof-of-delivery metadata: note, photo URI metadata, failure reason taxonomy. — completed 15. Add native proof photo URI capture from camera/library. — completed -16. Add binary proof media upload/native capture: photo file upload, signature drawing, barcode scanning. — completed as app-side boundary +16. Add binary proof media upload/native capture: photo file upload and signature drawing. — completed as app-side boundary; barcode proof capture is not in the current app scope 17. Add offline queue/retry policy for driver events and proof media. — completed as durable app-side queue boundary 18. Add app-side offline queue retention/discard thresholds after repeated failure, route completion, or driver sign-out/session reset. — completed, including explicit app session reset cleanup action 19. Add release-readiness checklist for physical iOS/Android smoke matrix and production store/privacy disclosure evidence. — completed as documentation checklist; real device/store evidence remains pending diff --git a/docs/release-evidence-manifest.template.md b/docs/release-evidence-manifest.template.md index da1bd34..040c974 100644 --- a/docs/release-evidence-manifest.template.md +++ b/docs/release-evidence-manifest.template.md @@ -89,7 +89,7 @@ Use sanitized references to external screenshots/videos/logs only. | Continuous/background-capable location task | pending | pending | pending | pending | pending | | Proof photo capture from camera/library | pending | pending | pending | pending | pending | | Proof media scan rejection UX | pending | pending | pending | pending | pending | -| Signature and barcode proof capture | pending | pending | pending | pending | pending | +| Signature proof capture | pending | pending | pending | pending | pending | | Offline queue retry/discard UI after network loss | pending | pending | pending | pending | pending | | Token expiry, invalid persisted token, or live downstream `401` recovery | pending | pending | pending | pending | pending | | Driver session reset/sign-out cleanup | pending | pending | pending | pending | pending | diff --git a/docs/release-readiness.md b/docs/release-readiness.md index bfc99c2..3cfe014 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -68,7 +68,8 @@ Before production release, capture evidence on at least one real iPhone and one | Area | iPhone evidence | Android evidence | Notes | | --- | --- | --- | --- | | Fresh install and app launch | pending | pending | Include app version/build identifier. | -| E.164 phone lookup | pending | pending | Verify tenant/company context before route data. | +| E.164 phone + six-digit PIN login | pending | pending | Verify no company/route data appears before account authentication. | +| First invitation registration | pending | pending | Use an administrator-created test invitation; confirm the app does not request or create a Shopify invitation and does not collect a driver name. | | Company guidance and support contact display | pending | pending | Confirm multi-company wording. | | Consent gate and retry/error handling | pending | pending | Verify consent versions/copy source. | | Assigned route and stop list | pending | pending | Use shop/route timezone `deliveryDate`. | @@ -77,9 +78,10 @@ Before production release, capture evidence on at least one real iPhone and one | Continuous/background-capable location task | pending | pending | Confirm native background configuration and OS prompts. | | Proof photo capture from camera/library | pending | pending | Use synthetic proof media. | | Proof media scan rejection UX | pending | pending | Local mock mode now exposes `scan_rejected`; live mode can use server `PROOF_MEDIA_REJECTED`. Confirm rejected photos show recapture guidance and are not queued as retryable proof. | -| Signature and barcode proof capture | pending | pending | Confirm unavailable/denied states. | +| Signature proof capture | pending | pending | Barcode proof capture is outside the current app scope. | | Offline queue retry/discard UI after network loss | pending | pending | Confirm app restart hydration. | -| Token expiry, invalid persisted token, or live downstream `401` recovery | pending | pending | App clears expired/malformed SecureStore payloads before reuse and live downstream `401` driver access plus active route UI state before requiring phone lookup re-lookup; confirm on devices. | +| Token expiry, invalid persisted token, or live downstream `401` recovery | pending | pending | App keeps account and route tokens separate, refreshes account access before reacquiring route access, and returns to phone + PIN only when account refresh/authentication fails. | +| Deleted/unassigned route refresh | pending | pending | An authoritative empty/deleted assignment removes the route from the app without deleting the signed-in account; transient network failure keeps the last safe cache. | | Driver session reset/sign-out cleanup | pending | pending | Confirm reset stops tracking, clears SecureStore driver access, clears queued retry state, blanks lookup inputs, and returns to safe lookup state. | | Delivery finish or route completion cleanup | pending | pending | App-side finish now stops tracking, records/queues `ROUTE_COMPLETED`, and cleans route queue after recorded completion; confirm on devices. | @@ -90,10 +92,9 @@ Store/privacy metadata must match actual runtime behavior and server retention p - Foreground location use: active delivery route tracking and location updates. - Background location use: only after delivery start and only when native background tracking is enabled. - Camera/photos: proof-of-delivery photo capture/upload. -- Camera barcode scanning: proof barcode capture when available. - Contacts/address book: current app uses manual E.164 phone entry and should not request Contacts permissions unless a future owner-approved feature changes that. `npm run check:native-release` rejects source-controlled Android Contacts permissions or iOS Contacts usage descriptions before EAS evidence builds. -- Driver identifiers: phone lookup, server-issued driver access token, route assignment identifiers. -- Proof media: photo file, signature metadata, barcode metadata, and related stop/route identifiers. +- Driver identifiers: E.164 phone account, server-issued account/refresh and route-scoped access tokens, route assignment identifiers. +- Proof media: photo file, signature metadata, and related stop/route identifiers. - Offline queue: non-secret retry metadata and file URI references retained locally until retry/discard policy runs. - Offline queue app-side policy: pending driver event/proof-media retry items are discarded after five retained attempts, after 72 hours, when the completed route is explicitly purged, when driver sign-out/session reset clears local retry state, or when proof-media upload is rejected by the server scan hook. - Server proof-media rejection/retention support: `clever-delivery-server` now has a proof-media scan rejection hook, `DRIVER_PROOF_MEDIA_RETENTION_DAYS`, and `npm run driver:proof-media:cleanup` for local/manual or cron-style cleanup; production object storage, scanner backend, and scheduler deployment evidence are still pending. diff --git a/docs/repository-setup.md b/docs/repository-setup.md index af1eb89..1667ac5 100644 --- a/docs/repository-setup.md +++ b/docs/repository-setup.md @@ -12,7 +12,7 @@ This document records the repo baseline for the `clever-driver-app` implementati - Node floor: `.nvmrc` pins `20.19.4`; `package.json` allows Node `>=20.19.4` - Text normalization: `.editorconfig` sets UTF-8, LF, final newline, trailing-whitespace trim, and two-space indentation for source/docs files; `.gitattributes` normalizes reviewed TypeScript/JavaScript/Markdown/YAML/JSON/shell text files to LF and marks evidence/release/signing artifact patterns as binary - Entry point: `index.ts` registering `App.tsx` -- Current implementation depth: local Expo country-aware phone lookup, broad supported-country i18n metadata and phone formatting, company guidance, safe multi-company ambiguity guidance, consent gate, assigned-route screen, stop-card OS map handoff, driver access token handoff, native secure token persistence/expiry clearing, optional `EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL` live API mode with no-store/no-cookie driver API requests, live downstream `401` expired-token recovery through secure-token clearing plus phone re-lookup guidance, delivery-start foreground location permission gate, route-started driver event boundary, foreground one-shot `LOCATION_UPDATED` event sync, continuous background-capable `LOCATION_UPDATED` task setup, native proof photo URI capture, proof media upload references, local proof-media smoke mock modes, scanner-rejected proof photo recapture guidance, signature/barcode proof capture, richer stop delivered/failed proof metadata controls, durable app-side offline queue/retry for driver events and retryable proof media, explicit app-side offline queue retention/discard thresholds, delivery finish `ROUTE_COMPLETED` cleanup, driver session reset/sign-out cleanup for secure access plus queued retry state, and EAS preview/production native build-profile scaffolding; delivery-server now has a proof-media scan rejection hook and local cleanup runner, while server-issued token refresh/strong re-auth, production proof-media object storage/signed access/deployed scanner evidence, physical-device background smoke evidence, owner-controlled signing/store setup, and store/privacy disclosure evidence remain later slices +- Current implementation depth: native Expo phone + six-digit PIN account login, invitation + forced-PIN first registration without driver name, separate SecureStore account/route credentials, account refresh and account-bearer route lookup, authoritative deleted-route cache clearing, consent and assigned-route views, map/location tracking, proof capture/upload, offline retry/discard, route completion cleanup, version 1.0.0 Android native source, and EAS preview/production profile scaffolding. Shopify invitation/signup creation remains manual and outside app/server automation; SMS OTP, forgotten-PIN recovery, production signing/store approval, and final privacy evidence remain later owner-approved slices. ## Scripts @@ -38,8 +38,7 @@ point to the external evidence manifests/runbooks and explicitly forbid pasting private artifacts, signing material, screenshots, videos, secrets, raw PII, or completed manifests into GitHub. -`.github/workflows/ci.yml` runs on pull requests to `dev`/`main` and pushes to -`dev`/`main`. The workflow uses the Node version from `.nvmrc`, installs from +`.github/workflows/ci.yml` is manually dispatched when remote verification is needed, avoiding automatic Actions-minute use on every PR/push. The workflow uses the Node version from `.nvmrc`, installs from `package-lock.json` with `npm ci`, then runs the same source-controlled gates used before release-sensitive PRs: `npm run check:workspace`, `npm run lint`, `npm run check:native-release`, `npm run release:evidence:seed`, `npm run @@ -106,7 +105,7 @@ Before opening an implementation PR, re-check these repo baseline files together - `eas.json`: preview/internal and production/store profile settings, EAS environment names, require-commit policy, and app version source match the release evidence plan. - `npm run check:native-release`: local native release config preflight passes, while external owner-controlled blockers remain tracked in `docs/release-readiness.md`. - `npm run release:evidence:verify -- `: completed external release evidence manifests are checked before release approval; the completed manifest itself stays out of git. -- `.github/workflows/ci.yml`: PR/push CI keeps local source-controlled gates aligned with the documented release-sensitive validation commands. +- `.github/workflows/ci.yml`: manually dispatched CI keeps remote source-controlled gates aligned with the documented release-sensitive validation commands. - `.github/PULL_REQUEST_TEMPLATE.md`: target issue, change-control issue, concurrent-work gate, validation evidence, and context/wiki completion fields are filled before issue closure. - `CONTRIBUTING.md` and `SECURITY.md`: human workflow, security/privacy reporting, sensitive evidence handling, and generated-file guardrails stay current. - `docs/release-readiness.md`: physical-device smoke matrix, store/privacy disclosure checklist, and release blockers match current runtime behavior. @@ -117,9 +116,9 @@ Before opening an implementation PR, re-check these repo baseline files together These items are intentionally left for later issues because they affect API, compliance, release, or device behavior beyond this bootstrap: 1. Store/private distribution policy and owner-controlled EAS environment values for preview/production. -2. Server-issued driver session/access token refresh, OTP, managed identity, or stronger re-auth UX beyond the current app-side phone re-lookup recovery after short-lived token expiry. +2. Server-owned SMS OTP onboarding/recovery, forgotten-PIN policy, and any stronger managed identity or device-binding policy beyond the current account refresh session. 3. Consent legal copy source and consent version contract. -5. Production proof media storage policy: persistent photo/signature/barcode storage ownership, access, retention, and deletion rules. +5. Production proof media storage policy: persistent photo/signature storage ownership, access, retention, and deletion rules. 6. Store disclosure matrix and production privacy copy for continuous background location; tracked in `docs/release-readiness.md`. 7. Expo/EAS project ownership, App Store/Play Store signing ownership, and credential rotation policy. 8. Minimum supported iOS/Android versions and physical-device background-location smoke matrix; tracked in `docs/release-readiness.md`. diff --git a/docs/route-access-flow.md b/docs/route-access-flow.md index 9906efb..db96123 100644 --- a/docs/route-access-flow.md +++ b/docs/route-access-flow.md @@ -2,50 +2,75 @@ ## Purpose -This document records the current app-side route access, consent, assigned-route, and delivery-start event UX boundary. Product scenarios remain in `docs/project-brief.md`; server contract details are owned by `clever-delivery-server/docs/api/driver-route-access.md`, `clever-delivery-server/docs/api/driver-consents.md`, and `clever-delivery-server/docs/api/driver-assigned-route.md`. +This document records the current app-side account authentication, route access, consent, assigned-route, and delivery-start event UX boundary. Product scenarios remain in `docs/project-brief.md`; the delivery server owns the account, session, invitation verification, route access, consent, and assigned-route contracts. ## Current app behavior The app now has an interactive phone-first driver flow: -1. Driver selects a supported country, enters the phone number registered with dispatch in national format, enters driver name, and acknowledges required privacy/location consent. -2. App formats the national phone input for the selected country and normalizes it to E.164 before lookup. -3. App calls a `RouteAccessService` boundary shaped like delivery-server `POST /driver/route-access/lookup` with `routeContext: null`. -4. `ROUTES_FOUND` returns zero or more selectable route choices for a registered active phone. Each choice carries its own company guidance, route access identifiers, and short-lived driver access token. -5. From the driver's point of view, multi-company assignments are just multiple routes; each route card shows the company/shop and route metadata attached to that route. -6. The app records required `LOCATION_INFORMATION` and `PERSONAL_INFORMATION` consent through the `DriverConsentService` boundary for the selected/loaded route token. -7. Assigned route loading calls an `AssignedRouteService` boundary shaped like delivery-server `GET /driver/assigned-route` for each route choice. -8. Loaded routes render in `Pending`, `In Progress`, and `Completed` tabs; a route card can open detail or start delivery. -9. Delivery start requests foreground location permission and moves to `delivery_active` only when permission is granted. -10. Live tracking starts at the company/pickup step, then proceeds through ordered stops without presenting turn-by-turn instruction UI. -11. Each stop can play a local area tip, open stop details, capture required proof photo, and record optional same-day notes, location-specific tips, and additional notes. -12. Delivery finish stops the continuous location task, records or queues a `ROUTE_COMPLETED` driver event, and discards route-scoped local retry items only after route completion is recorded. -13. `INVITED` remains accepted as a legacy exact route-context response from the API, but the app no longer asks the driver for any external route access artifact. -14. `NO_ASSIGNED_ROUTE`, `NOT_FOUND`, `DISABLED`, `BLOCKED`, and API errors stay in safe user-visible states without exposing other tenant/driver data. +1. Driver selects a supported country and enters the dispatch-registered phone number in national format. The app normalizes it to E.164. +2. Existing accounts submit phone + six-digit PIN to `POST /driver/auth/login`; they are not asked for another invitation code. +3. First-registration mode submits phone + an existing Shopify invitation code + new six-digit PIN to `POST /driver/auth/verify-invite`. It does not collect a driver name and does not create or request the Shopify invitation. +4. Successful login or registration returns an account access/refresh session with `use: driver_account`, stored in native SecureStore without persisting the PIN or invitation code. +5. The app calls `POST /driver/route-access/lookup` with the account bearer token and `routeContext: null`; the phone number is not resent in this request. +6. `ROUTES_FOUND` returns zero or more selectable route choices. Each choice carries company guidance, route access identifiers, and its own short-lived route-scoped driver token. +7. From the driver's point of view, multi-company assignments are just multiple routes; each route card shows the company/shop and route metadata attached to that route. +8. The app records required `LOCATION_INFORMATION` and `PERSONAL_INFORMATION` consent through the selected route token, then loads assigned-route detail for each route choice. +9. Loaded routes render in `Pending`, `In Progress`, and `Completed` tabs; a route card can open detail or start delivery. +10. Delivery start requests foreground location permission and moves to `delivery_active` only when permission is granted. +11. Live tracking starts at the company/pickup step, then proceeds through ordered stops without presenting turn-by-turn instruction UI. +12. Each stop can play a local area tip, open stop details, capture required proof photo, and record optional delivery notes, location-specific tips, and additional notes. +13. Delivery finish stops continuous location, records or queues `ROUTE_COMPLETED`, and discards route-scoped local retry items only after route completion is recorded. +14. An authoritative empty route list, `NOT_FOUND`, or a missing prior assignment removes that route from the app and clears only route-scoped cache. Network errors retain the last safe cache. +15. `NO_ASSIGNED_ROUTE`, `DISABLED`, `BLOCKED`, and API errors stay in safe user-visible states without exposing other tenant/driver data. ## Local mock boundary -`App.tsx` uses `createMockRouteAccessService()` from `src/domain/routeAccess/routeAccess.ts`, `createMockDriverConsentService()` from `src/domain/consent/driverConsent.ts`, and `createMockAssignedRouteService()` from `src/domain/route/assignedRoute.ts` when no live delivery-server base URL is configured. The default mock returns a phone-resolvable route choice with company guidance, so the app can run the same phone → today route → route details → live tracking → stop details → arrival check → stop completed/completed deliveries flow without a live server. +`src/app/AppRoot.tsx` uses mock account-auth, route-access, consent, and assigned-route services when no live delivery-server base URL is configured. The default mock runs the same phone + PIN → account-authenticated route choice → route details → live tracking → stop proof/completion flow without a live server. It never pretends that an SMS was sent. Mock services are for local UX smoke only and do not replace backend integration tests. ## API client boundary -`src/domain/routeAccess/routeAccess.ts` exports `createRouteAccessApiClient({ baseUrl, fetchImpl })`, which posts: +`src/domain/driverAuth/driverAuth.ts` owns the account-auth client. Existing-account login posts: + +```http +POST /driver/auth/login +Content-Type: application/json +``` + +```json +{ "phone": "+14165550123", "pin": "123456" } +``` + +First registration posts the already-issued invitation code and new PIN: + +```http +POST /driver/auth/verify-invite +Content-Type: application/json +``` + +```json +{ "phone": "+14165550123", "inviteCode": "A1B2C3", "pin": "123456" } +``` + +`POST /driver/auth/refresh` exchanges the opaque refresh token for a new account session. All three responses must include an access token, refresh token, their expiries, `tokenType: Bearer`, a positive TTL, and `use: driver_account`. + +`src/domain/routeAccess/routeAccess.ts` exports `createRouteAccessApiClient({ baseUrl, fetchImpl })`, which posts only after account authentication: ```http POST /driver/route-access/lookup +Authorization: Bearer Content-Type: application/json ``` ```json { - "phoneE164": "+14165550123", "routeContext": null } ``` -The expected phone-first success response is `ROUTES_FOUND`; `routes` may be empty when the phone is registered but no active route is assigned: +The expected account-authenticated success response is `ROUTES_FOUND`; `routes` may be empty when the account has no active assignment: ```json { @@ -69,7 +94,7 @@ The expected phone-first success response is `ROUTES_FOUND`; `routes` may be emp "driverInstructions": ["Bring insulated bag"] }, "driverAccess": { - "accessToken": "", + "accessToken": "", "tokenType": "Bearer", "expiresAt": "2026-05-12T06:55:00.000Z", "ttlSeconds": 900, @@ -84,7 +109,7 @@ The expected phone-first success response is `ROUTES_FOUND`; `routes` may be emp Each non-empty route choice must include `driverAccess` token evidence (`accessToken`, `tokenType`, `expiresAt`, `ttlSeconds`, and `use`) so later consent and assigned-route clients can use the server-issued driver bearer token for that route. -A legacy `INVITED` response with the same single-route fields is still accepted for backward compatibility. `MULTIPLE_MATCHES` remains a safe display-only legacy response and must not include `driverAccess`, `routePlanId`, delivery stops, customer addresses, coordinates, order data, or proof data. In the current app UX, drivers do not type external route artifacts; ambiguous legacy responses should direct the driver back to phone lookup or dispatch support. +A legacy `INVITED` response with the same single-route fields is still accepted for backward compatibility. `MULTIPLE_MATCHES` remains a safe display-only legacy response and must not include `driverAccess`, `routePlanId`, delivery stops, customer addresses, coordinates, order data, or proof data. Ambiguous legacy responses direct the driver to the account route list or dispatch support. ## Consent API client boundary @@ -101,7 +126,7 @@ Content-Type: application/json "routeContext": "11111111-1111-4111-8111-111111111111", "recordedAt": "2026-05-12T06:20:00.000Z", "deviceContext": { "platform": "ios" }, - "appContext": { "appVersion": "0.1.0" }, + "appContext": { "appVersion": "1.0.0" }, "consents": [ { "type": "LOCATION_INFORMATION", "version": "location-v1", "accepted": true }, { "type": "PERSONAL_INFORMATION", "version": "privacy-v1", "accepted": true } @@ -109,7 +134,7 @@ Content-Type: application/json } ``` -The app-side response boundary accepts only `CONSENT_RECORDED` evidence and never treats consent submission as an assigned route/stop read. Production phone lookup returns the server-issued driver access token, and live mode builds the consent client from that token after persisting it through native secure storage. +The app-side response boundary accepts only `CONSENT_RECORDED` evidence and never treats consent submission as an assigned route/stop read. Production route lookup returns a route-scoped driver token, and live mode builds the consent client from that token after persisting it separately from account access. ## Assigned route API client boundary @@ -126,7 +151,7 @@ The expected response shape matches `clever-delivery-server/docs/api/driver-assi - `NO_ASSIGNED_ROUTE` returns a safe empty state. - HTTP/API failures stay in `consent_recorded` with a retry message. -The app moves to `route_ready` only after an `ASSIGNED_ROUTE` response. Stop cards expose `Open map`, which hands off to the OS map handler with coordinates first and formatted address fallback without committing to a provider SDK. From there, the driver can explicitly start delivery; the app requests foreground location permission at that point and enters `delivery_active` only when the OS grants permission. After `delivery_active`, `src/domain/events/driverEvents.ts` records `ROUTE_STARTED`, foreground one-shot `LOCATION_UPDATED`, continuous/background-capable `LOCATION_UPDATED`, `STOP_DELIVERED`, `STOP_FAILED`, and `ROUTE_COMPLETED` events to `POST /driver/events` with the active driver bearer token. Stop proof now stores metadata (`proof` payload with note/reason/source, uploaded photo media references, signature drawing evidence, and barcode scan evidence). The durable app-side offline queue can retain failed `ROUTE_STARTED`, `LOCATION_UPDATED`, `STOP_DELIVERED`, `STOP_FAILED`, `ROUTE_COMPLETED`, and retryable proof media upload attempts for retry or discard across app restarts. Scanner-rejected proof media is not treated as retryable; delivery-server has a proof-media scan rejection hook, while production object storage/signed access/deployed scanner evidence and physical-device background smoke evidence remain later slices. Duplicate event responses are treated idempotently as recorded. +The app moves to `route_ready` only after an `ASSIGNED_ROUTE` response. Stop cards expose `Open map`, which hands off to the OS map handler with coordinates first and formatted address fallback without committing to a provider SDK. From there, the driver can explicitly start delivery; the app requests foreground location permission at that point and enters `delivery_active` only when the OS grants permission. After `delivery_active`, `src/domain/events/driverEvents.ts` records `ROUTE_STARTED`, foreground one-shot `LOCATION_UPDATED`, continuous/background-capable `LOCATION_UPDATED`, `STOP_DELIVERED`, `STOP_FAILED`, and `ROUTE_COMPLETED` events to `POST /driver/events` with the active driver bearer token. Stop proof now stores metadata (`proof` payload with note/reason/source, uploaded photo media references and signature drawing evidence). The durable app-side offline queue can retain failed `ROUTE_STARTED`, `LOCATION_UPDATED`, `STOP_DELIVERED`, `STOP_FAILED`, `ROUTE_COMPLETED`, and retryable proof media upload attempts for retry or discard across app restarts. Scanner-rejected proof media is not treated as retryable; delivery-server has a proof-media scan rejection hook, while production object storage/signed access/deployed scanner evidence and physical-device background smoke evidence remain later slices. Duplicate event responses are treated idempotently as recorded. ## Durable offline queue boundary @@ -189,28 +214,24 @@ If the live server returns `422` with the Driver API error code `PROOF_MEDIA_REJ For physical-device smoke runs without a deployed scanner backend, local mock mode exposes a `Local proof media upload mock` selector with `success`, `failure`, and `scan_rejected`. This selector is only used when `EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL` is unset and the app is running on local mock services; live delivery-server mode ignores it. -## Signature and barcode proof boundary +## Signature proof boundary -`src/domain/proof/proofSignatureCapture.ts` records signature drawing evidence as vector metadata (`signatureId`, signer name, stroke count, point count) instead of storing raw image data in the driver event payload. - -`src/domain/proof/proofBarcodeCapture.ts` records barcode evidence (`barcodeId`, symbology, data, capturedAt) from the native scanner boundary. The Expo implementation uses `expo-camera`'s modern scanner when available on the device; unavailable or permission-denied paths stay visible and do not create proof evidence. +`src/domain/proof/proofSignatureCapture.ts` records signature drawing evidence as vector metadata (`signatureId`, signer name, stroke count, point count) instead of storing raw image data in the driver event payload. Barcode proof capture is not shipped in the current app scope. ## Runtime API mode -By default the app uses local mock services. Setting `EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL` switches phone lookup to the live delivery-server `POST /driver/route-access/lookup` API. A successful `ROUTES_FOUND` or legacy `INVITED` lookup stores the selected route's short-lived `driverAccess` token in Expo SecureStore via `src/platform/expo/secureStore/expoSecureDriverAccessTokenStore.ts`. The app clears denied lookup sessions and clears expired or malformed persisted token payloads before reuse. Downstream live consent, assigned-route, driver-event, and proof-media API clients are built from the active route lookup token via `src/api/deliveryServer/driverApiClients.ts`. - -If a live downstream consent, assigned-route, driver-event, proof-media upload, or offline retry call returns `401`, the app classifies the token as expired driver access. It shows phone re-lookup guidance, clears the secure token, stops/clears active route UI state, and leaves retryable event/proof submissions in the non-secret offline queue so they can be retried after the driver obtains a fresh route-scoped token. +By default the app uses local mock services. Setting `EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL` switches account login/registration/refresh and route lookup to the live delivery server. SecureStore schema v4 keeps required account access separate from optional selected-route access and invalidates legacy phone-only payloads. PINs and invitation codes are never persisted. -The persisted payload stores only the driver token and route access identifiers required for downstream consent/assigned-route calls. It does not change the server-owned token TTL, refresh policy, tenant boundary, or route/stop authorization checks. +When a downstream consent, assigned-route, driver-event, proof-media, or offline retry request returns `401`, the app refreshes the account session if necessary, performs account-authenticated route lookup, and retries once with the new route token. If account refresh or account-authenticated lookup is unauthorized, the app clears the account session and returns to phone + PIN login. Authoritative empty/deleted assignment results clear route access while keeping the account signed in. ## Follow-up -- Country-aware phone entry now uses the supported-country i18n catalog in `src/domain/phone/phoneEntry.ts`: country labels include localized country name, ISO, calling code, and language; search covers name/native name/ISO/calling code/locale/language; national input is normalized to E.164 before route access. The remaining slice from `docs/phone-verification-plan.md` is server-owned SMS OTP verification/cost controls and per-country SMS compliance/rate-limit policy. -- Define release environment profiles and any server-issued token refresh, OTP, managed identity, or stronger re-auth UX beyond the current phone re-lookup recovery for short-lived token expiry. +- Country-aware phone entry normalizes national input to the server-owned E.164 account identity. The remaining SMS slice is optional server-owned OTP registration/recovery with provider cost, compliance, fraud, and rate-limit controls. +- Define forgotten-PIN recovery and owner-approved SMS OTP onboarding before allowing registration without a Shopify invitation. - Add production proof-media object storage, signed access, scanner backend deployment/private evidence storage, and deployed cleanup/scheduler evidence. The delivery server already exposes a scan rejection hook and a local/manual cleanup runner via `npm run driver:proof-media:cleanup`. - Add physical-device background tracking smoke evidence and production privacy disclosures for updates emitted while the app process cannot reach the live delivery server. Expo SDK 54 requires foreground permission before background permission and native background configuration for real background tracking. ## Driver API cache and cookie policy -Driver API calls are bearer-token based and must not rely on ambient browser or WebView cookies. The app request helper applies `credentials: 'omit'`, `cache: 'no-store'`, `Cache-Control: no-store`, and `Pragma: no-cache` to live delivery-server fetches for route lookup, consent, assigned-route reads, driver events, and proof-media upload. This keeps the native app session boundary tied to the short-lived driver token in SecureStore and avoids stale route/proof responses being reused by an intermediate cache. +Driver API calls are bearer-token based and must not rely on ambient browser or WebView cookies. The app request helper applies `credentials: 'omit'`, `cache: 'no-store'`, `Cache-Control: no-store`, and `Pragma: no-cache` to live account-auth, route lookup, consent, assigned-route, event, and proof-media requests. This keeps the native session tied to server-issued account and route tokens and avoids stale route/proof responses being reused by an intermediate cache. -AsyncStorage remains limited to non-secret offline retry metadata and local file URI references. Driver access tokens stay in SecureStore and are cleared on expiry, malformed payload, downstream `401`, denied lookup, and explicit driver session reset. +AsyncStorage remains limited to non-secret offline retry metadata and local file URI references. Account and route access stay in SecureStore; the app clears only route cache for deleted assignments and clears the whole account on expired refresh, malformed payload, unauthorized account access, or explicit session reset. diff --git a/docs/route-osrm-android-smoke-checklist.md b/docs/route-osrm-android-smoke-checklist.md new file mode 100644 index 0000000..fba9dc5 --- /dev/null +++ b/docs/route-osrm-android-smoke-checklist.md @@ -0,0 +1,78 @@ +# Driver Route OSRM Android Smoke Checklist + +Date: 2026-06-04 +Scope: driver route guidance/OSRM validation only. Do not retest or redesign login/auth beyond normal driver access lookup. + +## Hard safety gates + +- Use only synthetic validation data marked with `[TEST]`, `TEST-OSRM`, or `validation-only.example.test`. +- Do not directly edit real production orders, customers, drivers, payments, or route status rows. +- Do not send notifications to real drivers/customers. +- Do not create or complete real payments/orders. +- Do not open an admin backdoor, auth bypass, sample-token loader, or hidden privileged session. +- Do not auto-deploy. Install/update the connected Android device only from an explicitly selected build artifact. + +## Environment precheck + +1. Confirm the Android device is connected and visible to the tooling. +2. Confirm the app build is from branch `cc-222-driver-route-osrm` or a PR artifact for that branch. +3. Confirm the delivery API is from branch `cc-222-route-osrm-server` or a PR artifact for that branch. +4. Confirm the delivery server exposes `GET /driver/assigned-route` and has an explicit OSRM base URL configured (`DRIVER_ROUTE_OSRM_BASE_URL` or `OSRM_BASE_URL`). +5. Confirm the OSRM service is private/internal or otherwise explicitly approved; never rely on a silent public-router default. + +## Synthetic route data requirements + +Each smoke route must include: + +- Route name prefixed with `[TEST]`. +- Order names containing `TEST-OSRM`. +- At least one route per app tab/state: upcoming, active, completed/fallback. +- Full stop address fields and finite latitude/longitude for every non-fallback stop. +- `routeGeometry` as GeoJSON `LineString` with at least two `[longitude, latitude]` points, or `null` for fallback validation. +- `routeMetrics.distanceMeters` in meters and `routeMetrics.durationSeconds` in seconds, or `null` when OSRM is unavailable. +- `routeStopPoints[]` with `deliveryStopId`, `sequence`, `inputCoordinates`, and snap evidence, without exposing unrelated drivers or orders. + +The app fixture source of truth for tab/scenario shape is: + +- `src/domain/route/assignedRouteValidationScenarios.ts` + +## Android smoke scenarios + +### 1. Upcoming route with OSRM metrics + +- Open the app on the connected Android device. +- Look up a synthetic upcoming route. +- Record evidence that Today’s Route shows: + - Route date, region, and ordered stops. + - Estimated Distance from `routeMetrics.distanceMeters`. + - Estimated Time from `routeMetrics.durationSeconds`. +- Open Route Details and confirm the same distance/duration are shown. +- Open Live Tracking and confirm the map panel says `OSRM route ready`. + +### 2. Active route with geometry + +- Start or select a synthetic active route without notifying real users. +- Confirm route sequence and current stop remain visible. +- Confirm distance/ETA use route-level metrics and do not require a third-party map SDK. +- Confirm stop address and coordinates are still available for native map launch. + +### 3. Completed/fallback route + +- Select a synthetic completed/fallback route where OSRM fields are null. +- Confirm the app remains usable and shows `Not available` for distance/time. +- Confirm the map panel says `Route geometry pending` rather than crashing. + +### 4. Regression guard + +- Confirm the app does not show any admin backdoor/session-loader/sample-token UI. +- Confirm login/auth redesign screens were not introduced. +- Confirm proof-photo/tip/arrival flows are not blocked by the OSRM fields being absent. + +## Evidence to capture + +- Build artifact id or commit SHA for app and server. +- Device model, Android version, and test timestamp. +- Redacted screenshots for each scenario above. +- Redacted assigned-route JSON showing only synthetic data. +- OSRM request/response status evidence without tokens, secrets, or real user PII. +- Cleanup/dry-run record showing synthetic data can be removed or is isolated. diff --git a/docs/server-implementation-plan-driver-tabs-ia.md b/docs/server-implementation-plan-driver-tabs-ia.md new file mode 100644 index 0000000..239fb4c --- /dev/null +++ b/docs/server-implementation-plan-driver-tabs-ia.md @@ -0,0 +1,377 @@ +# Server Implementation Plan — Driver App Tabs IA Follow-up + +Date: 2026-05-19 +Authoring scope: `clever-driver-app` documentation only +Server/shopify implementation status: **plan-only; no server/shopify code changed** + +## Purpose + +The driver app now treats `Home`, `Routes`, `Earnings`, and `Profile` as primary app pages. The app implementation intentionally keeps server-backed route history, earnings, profile update, and account deletion as placeholders. This document is the server-side follow-up plan needed to replace those placeholders with real delivery-api behavior later. + +## Current App Contract Assumptions + +- The app can use the existing driver bearer token after invite-code verification. +- The app can show current/future assigned routes from existing route access / assigned-route flows. +- The app does **not** call any new server endpoint for historical routes, earnings, profile update, or account deletion. +- `Completed` in the app means current-session completion only until route-history API exists. +- `Profile` uses locally persisted verified driver/session information until a self-profile endpoint exists. +- `Home` exposes a completed-route `Post-route review / tip` field as local-only beta state; the app copy explicitly says server sync is not connected yet. +- Existing assigned-route responses already drive current/future filtering in the app; they must continue to provide valid route `deliveryDate` and `timezone` values. +- Current driver bearer token usage is not yet a complete self-service API contract; server work must decide whether to broaden the existing driver token scope or issue/refresh a token with explicit self-service scopes. + +## Driver App Migration Contract + +The current app implementation is intentionally session-oriented. Server implementation should not assume that a new server response can be dropped directly into the existing `routeSessions` state without an app adapter/refactor. + +Required migration invariants: + +- **Route list vs actionable route detail:** route history should return summary records for list rendering. Starting/continuing a route should continue to use an actionable assigned-route/detail contract with stop/proof context. +- **Per-route server status:** future `Routes` must use server-derived per-route status/progress. The current app uses a single session-level `routeStatus` for the selected route, and non-selected routes are treated as upcoming. +- **Feature gates:** do not flip `routeHistory`, `earnings`, `profileUpdate`, or `accountDeletion` from placeholder to live UI until app integration tests prove the server contract and tenant/token scoping. +- **Tenant boundary:** profile, history, earnings, feedback, and deletion APIs must resolve the driver and shop/tenant from verified token evidence, not from whichever route was last selected in the app. +- **Timezone contract:** both existing assigned-route APIs and future route-history APIs must return a valid IANA timezone. App-side fallback to device-local date is a defensive beta fallback, not an acceptable server contract. +- **Profile freshness:** local profile display can drift; future Profile work needs either `GET /driver/profile`, token-refresh profile claims, or another explicit profile refresh path before edits are treated as durable. + +## Required Server Workstreams + +### 1. Auth / Token Scope Contract + +Goal: define the driver bearer token semantics before adding self-service route history, feedback, profile, deletion, or earnings APIs. + +Current risk: + +- Existing driver access was introduced for invite-code verification and consent/assigned-route flows. +- The new tabs imply broader self-service operations. +- Reusing the existing token without explicit scopes could blur consent, route assignment, and account-management boundaries. + +Required decisions: + +- Either broaden the existing driver token audience/scope or issue/refresh a new self-service token. +- Define scopes/claims for: + - route history read; + - actionable assigned route read; + - route feedback write; + - profile read/update; + - account deletion request; + - earnings read. +- Define refresh behavior and expiry handling for returning drivers. +- Ensure token-derived tenant/shop/driver identity is the only authority for self-service APIs. +- Decide whether multi-shop same-phone drivers receive one tenant-scoped token per shop or a token that can enumerate authorized tenant contexts. + +Tests: + +- Expired/invalid/missing token returns `401`. +- Token for one shop/tenant cannot access another shop/tenant. +- Token for consent/assigned-route only cannot call broader self-service APIs unless explicitly allowed. +- Refresh preserves driver, shop/tenant, displayName, and allowed scopes. + +### 2. Driver Route History API + +Goal: allow the Routes tab to show past, current, and future route history without overloading the current assigned-route endpoint. + +Candidate endpoint: + +```http +GET /driver/routes?from=YYYY-MM-DD&to=YYYY-MM-DD&status=pending|active|completed&cursor= +Authorization: Bearer +``` + +Candidate response: + +```json +{ + "data": { + "routes": [ + { + "routePlanId": "route-id", + "shopDomain": "store.myshopify.com", + "companyDisplayName": "Tomatono", + "name": "2026-05-19 Morning Route", + "deliveryDate": "2026-05-19", + "timezone": "Asia/Seoul", + "status": "completed", + "stopCount": 8, + "completedStopCount": 8, + "completedAt": "2026-05-19T08:30:00.000Z" + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + }, + "error": null +} +``` + +Required decisions: + +- Status semantics: pending vs active vs completed should be server-derived, not app-guessed. +- Date filtering must use route/shop timezone, not device local date. +- Token must scope results to the verified driver and tenant/shop boundary. +- Pagination is required before exposing unbounded history. +- Decide whether stop/proof summary appears in this list or requires route-detail endpoint. +- Return summary records for list rendering. Do not require the app to replay consent/assigned-route loading for historical rows. +- Define a separate route-detail/action contract for active/current routes when stop/proof context is needed. +- Include per-route progress fields such as `stopCount`, `completedStopCount`, `failedStopCount`, and route-level completion timestamps. +- Existing assigned-route responses must also return valid `deliveryDate` and IANA `timezone`, because the current app already filters current/future routes before route-history exists. +- Decide explicit failure behavior for missing/invalid timezone: reject server data, return a validation/configuration error, or exclude the route with observable diagnostics. + +Tests: + +- Driver can only see own route history. +- Multi-shop same-phone scenario stays tenant scoped. +- Past/current/future date filtering honors route timezone. +- Pagination and status filters compose correctly. +- Deleted/deactivated driver behavior is explicit. +- Non-selected routes can carry their own server-derived status instead of inheriting the selected route session status. +- Missing/invalid route timezone is tested for both assigned-route and route-history responses. + +### 3. Driver Route Feedback / Review Note API + +Goal: replace the Home completed-route `Post-route review / tip` local-only field with durable server sync when product wants this beta input persisted. + +Candidate endpoint: + +```http +POST /driver/routes/:routePlanId/feedback +Authorization: Bearer +Content-Type: application/json + +{ + "reviewNote": "Gate code worked; use west entrance next time.", + "submittedAt": "2026-05-19T08:45:00.000Z" +} +``` + +Candidate response: + +```json +{ + "data": { + "feedbackId": "route-feedback-id", + "routePlanId": "route-id", + "reviewNote": "Gate code worked; use west entrance next time.", + "submittedAt": "2026-05-19T08:45:00.000Z" + }, + "error": null +} +``` + +Required decisions: + +- Decide whether feedback is editable, append-only, or last-write-wins. +- Decide whether feedback is driver-private, merchant/admin-visible, or dispatcher-visible. +- Validate max length and reject unsafe content. +- Scope write access to the verified driver assigned to that route and tenant/shop. +- Decide whether feedback is allowed only after route completion or also while a route is active. +- Decide whether feedback should be synced through the existing offline submission queue. + +Tests: + +- Driver can only submit feedback for own assigned route. +- Cross-tenant feedback is impossible. +- Feedback before allowed route state is rejected if product requires completion first. +- Too-long/empty feedback is rejected consistently. +- Retry/idempotency behavior is deterministic if offline queue support is added. + +### 4. Driver Self Profile Read / Update API + +Goal: enable future Profile display-name editing and profile freshness. The current Profile page is read-only and displays local session data plus a placeholder. + +Candidate read endpoint: + +```http +GET /driver/profile +Authorization: Bearer +``` + +Candidate read response: + +```json +{ + "data": { + "driver": { + "id": "driver-id", + "phone": "+821012345678", + "displayName": "Minji Kim", + "status": "ACTIVE" + } + }, + "error": null +} +``` + +Candidate update endpoint: + +Candidate endpoint: + +```http +PATCH /driver/profile +Authorization: Bearer +Content-Type: application/json + +{ + "displayName": "Minji Kim" +} +``` + +Candidate response: + +```json +{ + "data": { + "driver": { + "id": "driver-id", + "phone": "+821012345678", + "displayName": "Minji Kim", + "status": "ACTIVE" + } + }, + "error": null +} +``` + +Required behavior: + +- `GET /driver/profile` or token-refresh profile claims must exist before the app treats server profile data as fresh. +- Verify driver bearer token. +- Scope update to token driver/shop boundary. +- Trim and validate `displayName`. +- Forbid mutation of phone, status, auth subject, role, tenant/shop, and route assignments. +- Return `401` for missing/expired/invalid token. +- Return `400` for invalid displayName. +- Return updated profile in a shape the app can persist locally. + +Tests: + +- Valid display name updates and trims. +- Empty/too-long display names fail. +- Phone/status/auth fields cannot be changed. +- Cross-tenant update is impossible. +- Read-after-update returns the updated profile. +- Returning-driver refresh does not regress to stale local displayName when the server profile changed. + +### 5. Driver Account Deletion / Deactivation Workflow + +Goal: replace the Profile warning-only account deletion placeholder with a legally/product-approved workflow. + +Safer first-pass endpoint: + +```http +POST /driver/account-deletion-requests +Authorization: Bearer +Content-Type: application/json + +{ + "confirmation": "DELETE", + "reason": "optional driver-entered reason" +} +``` + +Candidate response: + +```json +{ + "data": { + "requestId": "deletion-request-id", + "status": "REQUESTED" + }, + "error": null +} +``` + +Required product/legal decisions before implementation: + +- Direct delete vs deletion request vs deactivation vs anonymization. +- Retention rules for proof media, consent records, driver events, location records, audit logs, and Shopify merchant operational records. +- Recovery/cooling-off period. +- Merchant/admin notification and visibility. +- Active route assignment behavior when deletion is requested. +- Whether request should block future app login immediately. + +Tests: + +- Request requires driver token and explicit confirmation. +- Active route edge cases are handled consistently. +- Audit log is created. +- Retention/anonymization policy is enforced. + +### 6. Earnings Domain Contract + +Goal: replace the Earnings `Coming soon` placeholder only after business rules exist. + +Candidate endpoint: + +```http +GET /driver/earnings?period=YYYY-MM&cursor= +Authorization: Bearer +``` + +Candidate response: + +```json +{ + "data": { + "currency": "KRW", + "period": "2026-05", + "summary": { + "completedRoutes": 12, + "completedStops": 148, + "grossAmount": 0, + "adjustments": 0, + "estimatedPayout": 0 + }, + "items": [] + }, + "error": null +} +``` + +Required decisions: + +- Rate model: per route, per stop, distance/time, manual adjustment, or external payout system. +- Completed route/stop attribution source of truth. +- Currency and tax handling. +- Payout period and cutoff timezone. +- Admin/merchant visibility and dispute correction flow. +- Whether estimates can be shown before payout finalization. + +Tests: + +- Only completed/eligible work contributes to earnings. +- Tenant and driver scoping is enforced. +- Adjustments are auditable. +- Currency/period boundaries are deterministic. + +## OpenAPI / Docs Requirements + +When server implementation starts, update: + +- delivery-api OpenAPI/reference docs; +- driver app API contract notes; +- auth/token boundary documentation; +- driver app migration contract notes for route summary vs actionable route detail; +- consent/location retention documentation if deletion affects compliance data; +- route feedback retention and visibility documentation if review/tip sync is implemented; +- Shopify admin docs only if merchant/admin UI visibility changes. + +## Rollout Recommendation + +1. Define the auth/token scope contract first; every follow-up endpoint depends on this boundary. +2. Tighten existing assigned-route timezone guarantees before relying on current/future route filtering. +3. Implement route history summary API because it directly completes the Routes page promise. +4. Add the app adapter/integration tests for per-route server status before enabling the `routeHistory` feature gate. +5. Decide whether Home review/tip is a durable product feature; if yes, implement route feedback sync and offline/idempotency behavior. +6. Implement profile read, then displayName-only profile update with app-side edit UI work. +7. Plan account deletion/deactivation with product/legal review before implementation. +8. Delay Earnings until payout rules are explicitly approved. + +## Non-goals for Current App Task + +- No server code changes. +- No Shopify app code changes. +- No database migrations. +- No live endpoint calls from app placeholders. +- No current app-side profile edit UI. +- No current server sync for Home post-route review/tip. diff --git a/docs/server-plan-driver-app-next-scenarios.md b/docs/server-plan-driver-app-next-scenarios.md new file mode 100644 index 0000000..a772f0a --- /dev/null +++ b/docs/server-plan-driver-app-next-scenarios.md @@ -0,0 +1,68 @@ +# Server Plan: Driver App Next Scenario Corrections + +## Scope boundary + +This document is a plan-only queue for future server/API work discovered during the driver app correction pass. It lives in the app repo so app copy, docs, and placeholders can point to the future contract needs, but this pass must not edit `clever-route-server`, `shopify-clever`, or any server runtime. + +## 1. Route list/history contract + +### Current/future route list semantics +- Provide the driver app with route sessions that can be classified as current, upcoming, or past for the authenticated driver token. +- Include route timezone and delivery date/status fields so the app can preserve nearest-first current/upcoming ordering without guessing from device timezone alone. +- Preserve tenant and driver boundaries: a token must only expose routes assigned to that driver within the correct tenant/company context. + +### Past/completed route history semantics +- Add a separate route-history API instead of overloading the current/upcoming list. +- Support date range, timezone, route status, completion status, and proof/completion summary fields. +- Make pagination explicit before the app presents history as more than current-session completion. + +### Timezone/date status rules +- Server responses should include canonical route timezone and normalized route date/status. +- The API contract should define how same-day, future, past, cancelled, skipped, and completed sessions are classified. +- The app should continue hiding past routes from the main current/upcoming list until the route-history contract exists. + +## 2. Profile update contract + +### Editable fields +- Decide which fields a driver can edit directly, such as display name, preferred contact fields, or profile metadata. +- Phone number changes need stricter rules because phone is part of route access and tenant/driver identity matching. + +### Display name behavior +- Define whether display name is driver-owned, dispatch/admin-owned, or derived from route assignment data. +- Return the canonical display value in driver access/profile responses so local app copy does not imply fake persistence. + +### Audit and tenant/driver identity boundaries +- Persist profile changes with actor, tenant, driver identity, timestamp, source app/version, and before/after values. +- Reject updates that cross tenant boundaries or conflict with phone uniqueness requirements. + +## 3. Account deletion/request contract + +### Warning copy needs +- Provide app-safe copy explaining that deletion may affect route access, support workflows, audit records, and legal/retention obligations. +- Distinguish immediate account reset/logout from a true account deletion request. + +### Support/admin workflow +- Treat mobile account deletion as a request workflow unless legal/product requirements require direct self-service deletion. +- Include request status, support contact path, cancellation window if any, and admin handling ownership. + +### Data retention constraints +- Define which driver records, proof metadata, route events, consent records, and audit records must be retained. +- Define what can be deleted, anonymized, or hidden from future app sessions. + +## 4. Post-route review/tip contract + +### Payload shape +- Plan an endpoint or driver event for post-route review/tip data with `routePlanId`, driver identity, rating/category/text fields if applicable, idempotency key, app version, and submitted timestamp. +- Keep this separate from `ROUTE_COMPLETED` so completion remains reliable even when optional feedback fails. + +### Admin visibility +- Define whether dispatch/admin users can view feedback, where it appears, and whether it is route-level, stop-level, or tenant-level. +- Define moderation/review status if feedback can contain free text. + +### Privacy/moderation/retention +- Specify privacy rules for driver-authored notes and whether they are internal-only. +- Define retention, export, deletion, and moderation behavior before enabling server sync in the app. + +## App dependency note + +Until these contracts exist, the driver app must keep route history, profile editing, account deletion, and post-route review/tip sync visibly gated or local-only. Copy must not imply server persistence or dispatch/admin visibility for these features. diff --git a/docs/store-privacy-disclosure-draft.md b/docs/store-privacy-disclosure-draft.md index 2c8164c..5b22c1f 100644 --- a/docs/store-privacy-disclosure-draft.md +++ b/docs/store-privacy-disclosure-draft.md @@ -31,7 +31,7 @@ policy, third-party SDK inventory, and the approved EVNSolution privacy notice. The current app is a driver-only delivery app. It does not include advertising, tracking, analytics SDKs, social login, or third-party map SDKs in this repo. -The app can transmit data to `clever-delivery-server` only after an E.164 phone lookup establishes a driver route boundary. Local mock mode is +The app can transmit driver workflow data to `clever-delivery-server` only after E.164 phone + PIN account authentication (or one-time invitation registration) establishes an account boundary, followed by account-authenticated route lookup. Local mock mode is for development only and is not a production disclosure basis. Runtime source anchors for this worksheet: @@ -40,26 +40,26 @@ Runtime source anchors for this worksheet: - `docs/route-access-flow.md`: app-side API, event, proof, and offline queue boundaries - `src/domain/location/continuousLocationStream.ts`: continuous/background-capable location task guard - `src/domain/proof/proofPhotoCapture.ts`, `src/domain/proof/proofMediaUpload.ts`: proof photo capture/upload -- `src/domain/proof/proofSignatureCapture.ts`, `src/domain/proof/proofBarcodeCapture.ts`: signature/barcode proof metadata +- `src/domain/proof/proofSignatureCapture.ts`: signature proof metadata - `src/domain/offline/offlineSubmissionQueue.ts`: local retry retention/discard policy ## Data disclosure worksheet | Data or permission area | Current app behavior | App Store privacy review input | Google Play Data safety review input | Owner/legal decision needed | | --- | --- | --- | --- | --- | -| Phone number | Driver enters E.164 phone for route access lookup. | Likely Contact Info / Phone Number, linked to driver route access, purpose: App Functionality. | Likely Personal info / Phone number, collected for App functionality. | Confirm if phone is retained in production logs and privacy policy. | -| Contacts / address book | The current app does not request Contacts permissions or read the device address book. Driver phone lookup is manual E.164 entry only. | Usually no Contacts disclosure for the current build unless a future feature reads contacts. | Do not declare Contacts permissions for the current build; Google Play has announced a Contacts Permissions policy effective October 28, 2026, and future broad contact access should use minimum-scope alternatives or a separate approved issue. | Keep contacts access out of native config unless owner/legal approves a future contact-picker feature and updates store disclosures. | -| Route assignment identifiers | Server returns concrete route identifiers only after phone lookup resolves active assigned routes. | Likely Identifiers or Other Data depending on final App Store taxonomy selection; linked to user/driver, purpose: App Functionality. | Likely App activity or app-specific identifiers/other data; collected for App functionality. | Confirm exact store taxonomy after production server payload review. | +| Phone number | Driver enters an E.164-normalized phone for account login or first registration; route lookup then uses the account bearer. | Likely Contact Info / Phone Number, linked to driver account and route access, purpose: App Functionality. | Likely Personal info / Phone number, collected for App functionality. | Confirm phone retention, account deletion, and production log redaction policy. | +| Six-digit PIN / invitation code | PIN is submitted only to account login/registration and is not persisted by the app; the first-registration invitation is administrator-created and not requested by the app. | Treat as authentication data and do not expose it in analytics, screenshots, logs, backups, or support evidence. | Treat as authentication data and keep it out of logs/backups; confirm final account security disclosure. | Confirm server hashing, rate limits, reset/recovery, and invitation expiry policy. | +| Contacts / address book | The current app does not request Contacts permissions or read the device address book. Driver phone account entry is manual only. | Usually no Contacts disclosure for the current build unless a future feature reads contacts. | Do not declare Contacts permissions for the current build; Google Play has announced a Contacts Permissions policy effective October 28, 2026, and future broad contact access should use minimum-scope alternatives or a separate approved issue. | Keep contacts access out of native config unless owner/legal approves a future contact-picker feature and updates store disclosures. | +| Route assignment identifiers | Server returns concrete route identifiers only after account authentication and account-bearer lookup resolves active assignments. | Likely Identifiers or Other Data depending on final App Store taxonomy selection; linked to user/driver, purpose: App Functionality. | Likely App activity or app-specific identifiers/other data; collected for App functionality. | Confirm exact store taxonomy after production server payload review. | | Company/shop/route guidance | App displays company, shop, route name/date, pickup/support contact. | Usually not user-collected driver data by itself, but route assignment context is linked to driver workflow. | Usually app content/operational data, but route assignment context may be linked to driver. | Confirm whether any displayed support contact is personal data. | | Foreground location | App requests while-in-use location only after explicit delivery start and records location update events. | Location / Precise Location, linked to driver, purpose: App Functionality. | Location / Precise location, collected for App functionality. | Confirm whether approximate-only fallback is acceptable for any workflow. | | Background-capable location | App can start a named continuous location task after `delivery_active`; native config enables iOS/Android background support. | Location / Precise Location, linked to driver, purpose: App Functionality; disclose background use in review notes. | Location / Precise location and background location declaration; core purpose is active delivery tracking. | Confirm store review justification, in-app prominent disclosure, and production wording. | | Camera proof photo | App can launch camera through Expo ImagePicker and upload proof photo media. | User Content / Photos or Videos, linked to driver/stop/route, purpose: App Functionality. | Photos and videos, collected for App functionality. | Confirm if production build uses scoped picker/camera only and avoids broad media library permissions. | | Photo library proof attachment | App can attach proof photos from library through Expo ImagePicker. | User Content / Photos or Videos, linked to driver/stop/route, purpose: App Functionality. | Photos and videos, collected for App functionality. | Confirm Android permission manifest after native build and whether Play photo/video declaration is needed. | -| Barcode scanning | App can scan proof barcodes using camera and include barcode data/symbology in proof events. | User Content or Other Data depending on barcode contents; linked to stop/route, purpose: App Functionality. | App activity / Other user-generated content or Other data depending on barcode contents. | Define allowed barcode contents and prohibit customer PII unless approved. | | Signature proof | App stores signer name plus signature stroke/point counts as metadata, not raw signature image data. | User Content / Other User Content or Other Data; linked to proof event, purpose: App Functionality. | Personal info / Name if signer name is personal data; also app content/proof metadata. | Confirm whether signer name is driver, recipient, or other person and update privacy policy. | | Proof media references | Server returns media id, storage key, content type, upload time, optional hash/size; server-side scan rejection hook support exists before accepted media persistence, and rejected proof media is not queued as durable proof. | Identifiers / Other Data linked to proof media and route, purpose: App Functionality. | Files and docs or Other data, collected for App functionality depending on final Play form taxonomy. | Confirm production object storage, signed access, retention, deletion, deployed scanner backend, and monitoring evidence. | | Driver events | App sends route/stop events such as route started, location updated, stop delivered/failed, route completed. | Other Data / App Activity depending on final taxonomy; linked to driver, purpose: App Functionality. | App activity and/or Other data, collected for App functionality. | Confirm event retention and support/audit use. | -| Secure driver token | Short-lived server token is stored in Expo SecureStore and cleared on expiry/invalid/live downstream `401`/session reset. | If token is transmitted and retained server-side only as auth/session evidence, classify with identifiers only if store form requires it. | Device or other IDs / app-specific identifier only if final form treats token as an identifier. | Confirm token logging policy and any refresh/strong re-auth design beyond phone re-lookup recovery. | +| Secure account and route tokens | Account access/refresh and route-scoped driver access are stored separately in Expo SecureStore. Deleted assignments clear route cache; expired account refresh or session reset clears the account. | If tokens are treated as auth/session evidence, classify with identifiers only if the current store form requires it. | Device or other IDs / app-specific identifier only if the final form treats tokens as identifiers. | Confirm refresh-token logging, revocation, retention, and account recovery policy. | | Offline queue | AsyncStorage stores non-secret retry metadata, driver event payloads, proof media file URI references, attempts, timestamps, and errors until retry/discard/reset. | Disclose underlying data types represented in queued payloads; local-only queue metadata may not be collected until transmitted. | Disclose underlying collected data when retry sends it; local-only metadata still needs privacy review if included in backups/logs. | Confirm backup/exclusion policy and production log redaction. | ## Current collection and sharing assumptions @@ -69,7 +69,7 @@ Runtime source anchors for this worksheet: - Tracking / advertising: no tracking, advertising, data broker, or cross-app analytics use is evident in the current repository. - Third-party SDKs: current runtime uses Expo/React Native libraries for - location, task manager, camera/image picker, secure storage, and status bar. + location, task manager, image picker/camera proof photos, secure storage, and status bar. Owner must verify SDK privacy manifests / Play SDK disclosures before submission. - Sharing: current code sends driver data to EVNSolution-owned delivery server @@ -105,17 +105,16 @@ Required evidence before submission: including the October 28, 2026 location-button recommendation if submission timing or target SDK policy makes it applicable -### Photo and barcode proof justification +### Photo proof justification Draft review rationale: Camera/photo library access is limited to proof of -delivery during an active delivery flow. Barcode scanning is limited to proof -barcode capture. Failed, denied, unavailable, or cancelled capture flows do not -become durable proof evidence. +delivery during an active delivery flow. Failed, denied, unavailable, or +cancelled capture flows do not become durable proof evidence. Required evidence before submission: - native build manifest/permission audit for photo/video/media permissions -- physical-device camera/library/scanner success and denial evidence +- physical-device camera/library success and denial evidence - confirmation that broad photo/video library access is not requested unless an approved core use case requires it diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..8beb344 --- /dev/null +++ b/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/ios/.xcode.env b/ios/.xcode.env new file mode 100644 index 0000000..3d5782c --- /dev/null +++ b/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/ios/CleverDriver.xcodeproj/project.pbxproj b/ios/CleverDriver.xcodeproj/project.pbxproj new file mode 100644 index 0000000..82450a1 --- /dev/null +++ b/ios/CleverDriver.xcodeproj/project.pbxproj @@ -0,0 +1,543 @@ +// !$*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 */; }; + 3F9E9A3DCFCAC2F333C501B0 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = EA9F09250A4FE3F71FB9153A /* PrivacyInfo.xcprivacy */; }; + A874165BCA5BD3CAB4CBDA2D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698C0FC0DBAA2389860A0936 /* ExpoModulesProvider.swift */; }; + B855048434C16FA76C9B698A /* libPods-CleverDriver.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 812260B1AF448C83BE40663E /* libPods-CleverDriver.a */; }; + BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 13B07F961A680F5B00A75B9A /* CleverDriver.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CleverDriver.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = CleverDriver/Images.xcassets; sourceTree = ""; }; + 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = CleverDriver/Info.plist; sourceTree = ""; }; + 54B56C58FC7B8EF1A19048F8 /* Pods-CleverDriver.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CleverDriver.debug.xcconfig"; path = "Target Support Files/Pods-CleverDriver/Pods-CleverDriver.debug.xcconfig"; sourceTree = ""; }; + 5EFBAC03A7121D7BE4CC6BB1 /* Pods-CleverDriver.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CleverDriver.release.xcconfig"; path = "Target Support Files/Pods-CleverDriver/Pods-CleverDriver.release.xcconfig"; sourceTree = ""; }; + 698C0FC0DBAA2389860A0936 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-CleverDriver/ExpoModulesProvider.swift"; sourceTree = ""; }; + 812260B1AF448C83BE40663E /* libPods-CleverDriver.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-CleverDriver.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = CleverDriver/SplashScreen.storyboard; sourceTree = ""; }; + BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; }; + EA9F09250A4FE3F71FB9153A /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = CleverDriver/PrivacyInfo.xcprivacy; 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 = CleverDriver/AppDelegate.swift; sourceTree = ""; }; + F11748442D0722820044C1D9 /* CleverDriver-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "CleverDriver-Bridging-Header.h"; path = "CleverDriver/CleverDriver-Bridging-Header.h"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 13B07F8C1A680F5B00A75B9A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B855048434C16FA76C9B698A /* libPods-CleverDriver.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 13B07FAE1A68108700A75B9A /* CleverDriver */ = { + isa = PBXGroup; + children = ( + F11748412D0307B40044C1D9 /* AppDelegate.swift */, + F11748442D0722820044C1D9 /* CleverDriver-Bridging-Header.h */, + BB2F792B24A3F905000567C9 /* Supporting */, + 13B07FB51A68108700A75B9A /* Images.xcassets */, + 13B07FB61A68108700A75B9A /* Info.plist */, + AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, + EA9F09250A4FE3F71FB9153A /* PrivacyInfo.xcprivacy */, + ); + name = CleverDriver; + sourceTree = ""; + }; + 2D16E6871FA4F8E400B85C8A /* Frameworks */ = { + isa = PBXGroup; + children = ( + ED297162215061F000B7C4FE /* JavaScriptCore.framework */, + 812260B1AF448C83BE40663E /* libPods-CleverDriver.a */, + ); + name = Frameworks; + sourceTree = ""; + }; + 555DFE3BFCB078283ED32D93 /* ExpoModulesProviders */ = { + isa = PBXGroup; + children = ( + 9DAF7BDA81F28245A0A1083A /* CleverDriver */, + ); + name = ExpoModulesProviders; + sourceTree = ""; + }; + 5ACB9F091EA5FF246E467C32 /* Pods */ = { + isa = PBXGroup; + children = ( + 54B56C58FC7B8EF1A19048F8 /* Pods-CleverDriver.debug.xcconfig */, + 5EFBAC03A7121D7BE4CC6BB1 /* Pods-CleverDriver.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 832341AE1AAA6A7D00B99B32 /* Libraries */ = { + isa = PBXGroup; + children = ( + ); + name = Libraries; + sourceTree = ""; + }; + 83CBB9F61A601CBA00E9B192 = { + isa = PBXGroup; + children = ( + 13B07FAE1A68108700A75B9A /* CleverDriver */, + 832341AE1AAA6A7D00B99B32 /* Libraries */, + 83CBBA001A601CBA00E9B192 /* Products */, + 2D16E6871FA4F8E400B85C8A /* Frameworks */, + 5ACB9F091EA5FF246E467C32 /* Pods */, + 555DFE3BFCB078283ED32D93 /* ExpoModulesProviders */, + ); + indentWidth = 2; + sourceTree = ""; + tabWidth = 2; + usesTabs = 0; + }; + 83CBBA001A601CBA00E9B192 /* Products */ = { + isa = PBXGroup; + children = ( + 13B07F961A680F5B00A75B9A /* CleverDriver.app */, + ); + name = Products; + sourceTree = ""; + }; + 9DAF7BDA81F28245A0A1083A /* CleverDriver */ = { + isa = PBXGroup; + children = ( + 698C0FC0DBAA2389860A0936 /* ExpoModulesProvider.swift */, + ); + name = CleverDriver; + sourceTree = ""; + }; + BB2F792B24A3F905000567C9 /* Supporting */ = { + isa = PBXGroup; + children = ( + BB2F792C24A3F905000567C9 /* Expo.plist */, + ); + name = Supporting; + path = CleverDriver/Supporting; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 13B07F861A680F5B00A75B9A /* CleverDriver */ = { + isa = PBXNativeTarget; + buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "CleverDriver" */; + buildPhases = ( + 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, + 0C69C599F9F07C38638EB8B7 /* [Expo] Configure project */, + 13B07F871A680F5B00A75B9A /* Sources */, + 13B07F8C1A680F5B00A75B9A /* Frameworks */, + 13B07F8E1A680F5B00A75B9A /* Resources */, + 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, + 220A1722EDE4B31F129C20D3 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = CleverDriver; + productName = CleverDriver; + productReference = 13B07F961A680F5B00A75B9A /* CleverDriver.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; + }; + }; + }; + buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "CleverDriver" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 83CBB9F61A601CBA00E9B192; + productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 13B07F861A680F5B00A75B9A /* CleverDriver */, + ); + }; +/* 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 */, + 3F9E9A3DCFCAC2F333C501B0 /* 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-CleverDriver-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; + }; + 0C69C599F9F07C38638EB8B7 /* [Expo] Configure project */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/.xcode.env", + "$(SRCROOT)/.xcode.env.local", + "$(SRCROOT)/CleverDriver/CleverDriver.entitlements", + "$(SRCROOT)/Pods/Target Support Files/Pods-CleverDriver/expo-configure-project.sh", + ); + name = "[Expo] Configure project"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(SRCROOT)/Pods/Target Support Files/Pods-CleverDriver/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-CleverDriver/expo-configure-project.sh\"\n"; + }; + 220A1722EDE4B31F129C20D3 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-CleverDriver/Pods-CleverDriver-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-CleverDriver/Pods-CleverDriver-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-CleverDriver/Pods-CleverDriver-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXTaskManager/ExpoTaskManager_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_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", + ); + 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}/ExpoTaskManager_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_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", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-CleverDriver/Pods-CleverDriver-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 13B07F871A680F5B00A75B9A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */, + A874165BCA5BD3CAB4CBDA2D /* ExpoModulesProvider.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 13B07F941A680F5B00A75B9A /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 54B56C58FC7B8EF1A19048F8 /* Pods-CleverDriver.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = CleverDriver/CleverDriver.entitlements; + CURRENT_PROJECT_VERSION = 1; + ENABLE_BITCODE = NO; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "FB_SONARKIT_ENABLED=1", + ); + INFOPLIST_FILE = CleverDriver/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; + PRODUCT_BUNDLE_IDENTIFIER = com.evns.cleverdriverapp; + PRODUCT_NAME = CleverDriver; + SWIFT_OBJC_BRIDGING_HEADER = "CleverDriver/CleverDriver-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 13B07F951A680F5B00A75B9A /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5EFBAC03A7121D7BE4CC6BB1 /* Pods-CleverDriver.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = CleverDriver/CleverDriver.entitlements; + CURRENT_PROJECT_VERSION = 1; + INFOPLIST_FILE = CleverDriver/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.0; + OTHER_LDFLAGS = ( + "$(inherited)", + "-ObjC", + "-lc++", + ); + OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; + PRODUCT_BUNDLE_IDENTIFIER = com.evns.cleverdriverapp; + PRODUCT_NAME = CleverDriver; + SWIFT_OBJC_BRIDGING_HEADER = "CleverDriver/CleverDriver-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + VERSIONING_SYSTEM = "apple-generic"; + }; + 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 "CleverDriver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 13B07F941A680F5B00A75B9A /* Debug */, + 13B07F951A680F5B00A75B9A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "CleverDriver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 83CBBA201A601CBA00E9B192 /* Debug */, + 83CBBA211A601CBA00E9B192 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; +} diff --git a/ios/CleverDriver.xcodeproj/xcshareddata/xcschemes/CleverDriver.xcscheme b/ios/CleverDriver.xcodeproj/xcshareddata/xcschemes/CleverDriver.xcscheme new file mode 100644 index 0000000..289d1ca --- /dev/null +++ b/ios/CleverDriver.xcodeproj/xcshareddata/xcschemes/CleverDriver.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/CleverDriver.xcworkspace/contents.xcworkspacedata b/ios/CleverDriver.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..c1b0ab8 --- /dev/null +++ b/ios/CleverDriver.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/CleverDriver/AppDelegate.swift b/ios/CleverDriver/AppDelegate.swift new file mode 100644 index 0000000..a7887e1 --- /dev/null +++ b/ios/CleverDriver/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/ios/CleverDriver/CleverDriver-Bridging-Header.h b/ios/CleverDriver/CleverDriver-Bridging-Header.h new file mode 100644 index 0000000..8361941 --- /dev/null +++ b/ios/CleverDriver/CleverDriver-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/ios/CleverDriver/CleverDriver.entitlements b/ios/CleverDriver/CleverDriver.entitlements new file mode 100644 index 0000000..f683276 --- /dev/null +++ b/ios/CleverDriver/CleverDriver.entitlements @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ios/CleverDriver/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/ios/CleverDriver/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png new file mode 100644 index 0000000..ac881f6 Binary files /dev/null and b/ios/CleverDriver/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/ios/CleverDriver/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/CleverDriver/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..90d8d4c --- /dev/null +++ b/ios/CleverDriver/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/ios/CleverDriver/Images.xcassets/Contents.json b/ios/CleverDriver/Images.xcassets/Contents.json new file mode 100644 index 0000000..ed285c2 --- /dev/null +++ b/ios/CleverDriver/Images.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "version" : 1, + "author" : "expo" + } +} diff --git a/ios/CleverDriver/Images.xcassets/SplashScreenBackground.colorset/Contents.json b/ios/CleverDriver/Images.xcassets/SplashScreenBackground.colorset/Contents.json new file mode 100644 index 0000000..15f02ab --- /dev/null +++ b/ios/CleverDriver/Images.xcassets/SplashScreenBackground.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors": [ + { + "color": { + "components": { + "alpha": "1.000", + "blue": "1.00000000000000", + "green": "1.00000000000000", + "red": "1.00000000000000" + }, + "color-space": "srgb" + }, + "idiom": "universal" + } + ], + "info": { + "version": 1, + "author": "expo" + } +} \ No newline at end of file diff --git a/ios/CleverDriver/Info.plist b/ios/CleverDriver/Info.plist new file mode 100644 index 0000000..b4f186d --- /dev/null +++ b/ios/CleverDriver/Info.plist @@ -0,0 +1,85 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Clever Driver + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.1.0 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleURLSchemes + + clever-driver + com.evns.cleverdriverapp + + + + CFBundleVersion + 1 + LSMinimumSystemVersion + 12.0 + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + + + NSCameraUsageDescription + Allow Clever Driver to take proof photos for active deliveries. + NSLocationAlwaysAndWhenInUseUsageDescription + Allow Clever Driver to use your location for active delivery tracking. + NSLocationAlwaysUsageDescription + Allow Clever Driver to use your location for active delivery tracking. + NSLocationWhenInUseUsageDescription + Allow Clever Driver to use your location while you are actively delivering a route. + NSPhotoLibraryUsageDescription + Allow Clever Driver to attach proof photos for active deliveries. + RCTNewArchEnabled + + UIBackgroundModes + + fetch + location + + UILaunchStoryboardName + SplashScreen + UIRequiredDeviceCapabilities + + arm64 + + UIRequiresFullScreen + + UIStatusBarStyle + UIStatusBarStyleDefault + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UIUserInterfaceStyle + Automatic + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/CleverDriver/PrivacyInfo.xcprivacy b/ios/CleverDriver/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..5bb83c5 --- /dev/null +++ b/ios/CleverDriver/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/ios/CleverDriver/SplashScreen.storyboard b/ios/CleverDriver/SplashScreen.storyboard new file mode 100644 index 0000000..6c99b2a --- /dev/null +++ b/ios/CleverDriver/SplashScreen.storyboard @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ios/CleverDriver/Supporting/Expo.plist b/ios/CleverDriver/Supporting/Expo.plist new file mode 100644 index 0000000..750be02 --- /dev/null +++ b/ios/CleverDriver/Supporting/Expo.plist @@ -0,0 +1,12 @@ + + + + + EXUpdatesCheckOnLaunch + ALWAYS + EXUpdatesEnabled + + EXUpdatesLaunchWaitMs + 0 + + \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..1989e6a --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,63 @@ +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 'CleverDriver' 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), + ) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..b798f86 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,2070 @@ +PODS: + - EXConstants (18.0.13): + - ExpoModulesCore + - EXImageLoader (6.0.0): + - ExpoModulesCore + - React-Core + - Expo (54.0.34): + - 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.13): + - ExpoModulesCore + - ExpoFileSystem (19.0.22): + - ExpoModulesCore + - ExpoFont (14.0.11): + - ExpoModulesCore + - ExpoImagePicker (17.0.11): + - ExpoModulesCore + - ExpoKeepAwake (15.0.8): + - ExpoModulesCore + - ExpoLocation (19.0.8): + - ExpoModulesCore + - ExpoModulesCore (3.0.30): + - 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 + - ExpoSecureStore (15.0.8): + - ExpoModulesCore + - ExpoSpeech (14.0.8): + - ExpoModulesCore + - EXTaskManager (14.0.9): + - ExpoModulesCore + - UMAppLoader + - FBLazyVector (0.81.5) + - hermes-engine (0.81.5): + - hermes-engine/Pre-built (= 0.81.5) + - hermes-engine/Pre-built (0.81.5) + - 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-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) + - 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 + - UMAppLoader (6.0.8) + - Yoga (0.0.0) + +DEPENDENCIES: + - EXConstants (from `../node_modules/expo/node_modules/expo-constants/ios`) + - EXImageLoader (from `../node_modules/expo-image-loader/ios`) + - Expo (from `../node_modules/expo`) + - ExpoAsset (from `../node_modules/expo/node_modules/expo-asset/ios`) + - ExpoFileSystem (from `../node_modules/expo/node_modules/expo-file-system/ios`) + - ExpoFont (from `../node_modules/expo/node_modules/expo-font/ios`) + - ExpoImagePicker (from `../node_modules/expo-image-picker/ios`) + - ExpoKeepAwake (from `../node_modules/expo/node_modules/expo-keep-awake/ios`) + - ExpoLocation (from `../node_modules/expo-location/ios`) + - ExpoModulesCore (from `../node_modules/expo-modules-core`) + - ExpoSecureStore (from `../node_modules/expo-secure-store/ios`) + - ExpoSpeech (from `../node_modules/expo-speech/ios`) + - EXTaskManager (from `../node_modules/expo-task-manager/ios`) + - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) + - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - 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-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`) + - "RNCAsyncStorage (from `../node_modules/@react-native-async-storage/async-storage`)" + - UMAppLoader (from `../node_modules/unimodules-app-loader/ios`) + - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) + +EXTERNAL SOURCES: + EXConstants: + :path: "../node_modules/expo/node_modules/expo-constants/ios" + EXImageLoader: + :path: "../node_modules/expo-image-loader/ios" + Expo: + :path: "../node_modules/expo" + ExpoAsset: + :path: "../node_modules/expo/node_modules/expo-asset/ios" + ExpoFileSystem: + :path: "../node_modules/expo/node_modules/expo-file-system/ios" + ExpoFont: + :path: "../node_modules/expo/node_modules/expo-font/ios" + ExpoImagePicker: + :path: "../node_modules/expo-image-picker/ios" + ExpoKeepAwake: + :path: "../node_modules/expo/node_modules/expo-keep-awake/ios" + ExpoLocation: + :path: "../node_modules/expo-location/ios" + ExpoModulesCore: + :path: "../node_modules/expo-modules-core" + ExpoSecureStore: + :path: "../node_modules/expo-secure-store/ios" + ExpoSpeech: + :path: "../node_modules/expo-speech/ios" + EXTaskManager: + :path: "../node_modules/expo-task-manager/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 + 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-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" + RNCAsyncStorage: + :path: "../node_modules/@react-native-async-storage/async-storage" + UMAppLoader: + :path: "../node_modules/unimodules-app-loader/ios" + Yoga: + :path: "../node_modules/react-native/ReactCommon/yoga" + +SPEC CHECKSUMS: + EXConstants: fce59a631a06c4151602843667f7cfe35f81e271 + EXImageLoader: 189e3476581efe3ad4d1d3fb4735b7179eb26f05 + Expo: 514ae3ec54a086aab509f255abfbf37be0fcc0e2 + ExpoAsset: 9e5a7c9c0a9fb83ad5b2bb365141f2bd7f289b66 + ExpoFileSystem: ad991457efaa14253723f1dda997eac6024c700d + ExpoFont: f543ce20a228dd702813668b1a07b46f51878d47 + ExpoImagePicker: ade85e148f735fc7e6f27dfe3eef28eb3887e90a + ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296 + ExpoLocation: d5b61cb4970fa982e39ca94246a206a0c3b812ca + ExpoModulesCore: 9e6a5514828e7dd5ded9e99d33557be2d284e660 + ExpoSecureStore: d32f751874a2ceb5aaeebeb3578e165c1ba2b24a + ExpoSpeech: d22b56570ccfab5a27112dc9691f1ac83537de26 + EXTaskManager: 6f1a66e4c8cc6df6e24c3d90928704bc3013eae5 + FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12 + hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172 + 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-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 + RNCAsyncStorage: 3a4f5e2777dae1688b781a487923a08569e27fe4 + UMAppLoader: 145337733539f9af9b3dd97eeaa7f3bd97cacb23 + Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a + +PODFILE CHECKSUM: bc2e8c0c37854f4a88b164aa02643e23734cbb16 + +COCOAPODS: 1.16.2 diff --git a/ios/Podfile.properties.json b/ios/Podfile.properties.json new file mode 100644 index 0000000..417e2e5 --- /dev/null +++ b/ios/Podfile.properties.json @@ -0,0 +1,5 @@ +{ + "expo.jsEngine": "hermes", + "EX_DEV_CLIENT_NETWORK_INSPECTOR": "true", + "newArchEnabled": "true" +} diff --git a/package-lock.json b/package-lock.json index b845fe3..68af45e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,85 +1,80 @@ { "name": "clever-driver-app", - "version": "0.1.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clever-driver-app", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { + "@maplibre/maplibre-react-native": "^11.3.4", "@react-native-async-storage/async-storage": "2.2.0", - "expo": "~54.0.33", - "expo-camera": "~17.0.10", - "expo-image-picker": "~17.0.11", - "expo-location": "~19.0.8", - "expo-secure-store": "~15.0.8", - "expo-speech": "~14.0.8", - "expo-status-bar": "~3.0.9", - "expo-task-manager": "~14.0.9", - "libphonenumber-js": "1.13.1", - "react": "19.1.0", - "react-native": "0.81.5" + "expo": "~56.0.15", + "expo-camera": "~56.0.8", + "expo-dev-client": "~56.0.22", + "expo-image-picker": "~56.0.20", + "expo-location": "~56.0.20", + "expo-notifications": "~56.0.20", + "expo-secure-store": "~56.0.4", + "expo-speech": "~56.0.3", + "expo-status-bar": "~56.0.4", + "expo-task-manager": "~56.0.21", + "libphonenumber-js": "1.13.6", + "react": "19.2.3", + "react-native": "0.85.3", + "react-native-safe-area-context": "~5.7.0" }, "devDependencies": { - "@types/node": "^20.19.41", - "@types/react": "~19.1.0", + "@types/node": "^20.19.43", + "@types/react": "~19.2.14", "eslint": "^9.0.0", - "eslint-config-expo": "~10.0.0", - "tsx": "^4.20.6", - "typescript": "~5.9.2" + "eslint-config-expo": "~56.0.4", + "tsx": "^4.22.4", + "typescript": "~6.0.3" }, "engines": { "node": ">=20.19.4" } }, - "node_modules/@0no-co/graphql.web": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@0no-co/graphql.web/-/graphql.web-1.2.0.tgz", - "integrity": "sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==", - "license": "MIT", - "peerDependencies": { - "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" - }, - "peerDependenciesMeta": { - "graphql": { - "optional": true - } - } - }, "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==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.10.4" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -95,20 +90,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -119,13 +100,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -135,25 +116,25 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -172,17 +153,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.3.tgz", - "integrity": "sha512-RpLYy2sb51oNLjuu1iD3bwBqCBWUzjO0ocp+iaCP/lJtb2CPLcnC2Fftw+4sAzaMELGeWTgExSKADbdo0GFVzA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.29.0", + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "engines": { @@ -202,12 +183,12 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", - "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-annotate-as-pure": "^7.29.7", "regexpu-core": "^6.3.1", "semver": "^6.3.1" }, @@ -265,49 +246,49 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", - "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -317,35 +298,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.1" + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -355,14 +336,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -372,165 +353,79 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", - "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.25.9.tgz", - "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "license": "MIT", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "license": "MIT", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "license": "MIT" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "license": "MIT", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -540,14 +435,14 @@ } }, "node_modules/@babel/plugin-proposal-decorators": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.0.tgz", - "integrity": "sha512-CVBVv3VY/XRMxRYq5dwr2DS7/MvqPm23cOCjbwNnVrfOqcWlnefua1uUs0sjdKOGjvPUG633o07uWzJq4oI6dA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-decorators": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -557,12 +452,12 @@ } }, "node_modules/@babel/plugin-proposal-export-default-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.27.1.tgz", - "integrity": "sha512-hjlsMBl1aJc5lp8MoCDEZCiYzlgdRAShOjAfRw6X+GlpLpUPU7c3XNLsKFZbQk/1cRzBlJ7CXg3xJAJMrFa1Uw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -571,22 +466,25 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-bigint": { + "node_modules/@babel/plugin-syntax-dynamic-import": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -595,25 +493,28 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -622,13 +523,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-decorators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", - "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -637,10 +538,10 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-dynamic-import": { + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" @@ -649,28 +550,25 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-export-default-from": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.28.6.tgz", - "integrity": "sha512-Svlx1fjJFnNz0LZeUaybRukSxZI3KkpApUmIRzEdXC5k8ErTOz0OD0kNrICi5Vc3GlpP5ZCeRyRO+mfWTSz+iQ==", + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" + "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-flow": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.28.6.tgz", - "integrity": "sha512-D+OrJumc9McXNEBI/JmFnc/0uCM2/Y3PEBG3gfV3QIYkKv5pvnpzFrl1kYCrcHJP8nOeFB/SHi1IHz29pNGuew==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -679,13 +577,15 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -694,37 +594,30 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -733,85 +626,113 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.12.0" } }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -820,13 +741,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -835,13 +756,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -850,30 +772,29 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "@babel/core": "^7.0.0" } }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -882,15 +803,17 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1" + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -899,13 +822,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -914,14 +837,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -930,34 +853,29 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.12.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -966,14 +884,15 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -982,14 +901,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", - "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -998,13 +916,17 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1013,14 +935,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-flow-strip-types": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.27.1.tgz", - "integrity": "sha512-G5eDKsu50udECw7DL2AcsysXiQyB7Nfg521t2OAJ4tbfTJ27doHLeF/vlI1NZGlLdbb/v+ibvtL1YBQqYOwJGg==", + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-syntax-flow": "^7.27.1" + "@babel/plugin-transform-react-jsx": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1029,14 +950,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1045,15 +966,18 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" }, "engines": { "node": ">=6.9.0" @@ -1062,13 +986,26 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1077,13 +1014,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1092,417 +1030,17 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.28.0.tgz", - "integrity": "sha512-D6Eujc2zMxKjfa4Zxl4GHMsmhKKZ9VpcqIchJLvwTxad9zWIYulwYItBovpDOoNLISpcZSXoDJ5gaGbQUDqViA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.28.6.tgz", - "integrity": "sha512-61bxqhiRfAACulXSLd/GxqmAedUSrRZIu/cbaT18T1CetkTmtDN15it7i80ru4DVqRK1WMxQhXs+Lf9kajm5Ow==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-syntax-jsx": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.27.1.tgz", - "integrity": "sha512-ykDdF5yI4f1WrAolLqeF3hmYU12j9ntLQl/AOG1HAS21jxyg1Q0/J/tpREuYLfatGdGmXp/3yS0ZA76kOlVq9Q==", - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.27.1.tgz", - "integrity": "sha512-JfuinvDOsD9FVMTHpzA/pBLisxpv1aSf+OIV8lgH3MuWrks19R27e6a6DipIg4aX1Zm9Wpb04p8wljfKrVSnPA==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.0.tgz", - "integrity": "sha512-jlaRT5dJtMaMCV6fAuLbsQMSwz/QkvaHOHOSXRitGGwSpR1blCY4KUKoyP2tYO8vJcqYe8cEj96cqSztv3uF9w==", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typescript": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", - "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-react": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.28.5.tgz", - "integrity": "sha512-Z3J8vhRq7CeLjdC58jLv4lnZ5RKFUJWqH5emvxmv9Hv3BD1T9R/Im713R4MTKwvFaV74ejZ3sM01LyEKk4ugNQ==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-transform-react-display-name": "^7.28.0", - "@babel/plugin-transform-react-jsx": "^7.27.1", - "@babel/plugin-transform-react-jsx-development": "^7.27.1", - "@babel/plugin-transform-react-pure-annotations": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-typescript": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", - "integrity": "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==", - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-typescript": "^7.28.5" + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1521,106 +1059,45 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse--for-generate-function-map": { - "name": "@babel/traverse", - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse--for-generate-function-map/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -1661,9 +1138,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -1678,9 +1155,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -1695,9 +1172,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -1712,9 +1189,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -1729,9 +1206,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -1746,9 +1223,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -1763,9 +1240,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -1780,9 +1257,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -1797,9 +1274,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -1814,9 +1291,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -1831,9 +1308,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -1848,9 +1325,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -1865,9 +1342,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -1882,9 +1359,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -1899,9 +1376,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -1916,9 +1393,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -1933,9 +1410,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -1950,9 +1427,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -1967,9 +1444,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -1984,9 +1461,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -2001,9 +1478,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -2018,9 +1495,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -2035,9 +1512,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -2052,9 +1529,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -2069,9 +1546,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -2086,9 +1563,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -2256,52 +1733,48 @@ } }, "node_modules/@expo/config": { - "version": "12.0.13", - "resolved": "https://registry.npmjs.org/@expo/config/-/config-12.0.13.tgz", - "integrity": "sha512-Cu52arBa4vSaupIWsF0h7F/Cg//N374nYb7HAxV0I4KceKA7x2UXpYaHOL7EEYYvp7tZdThBjvGpVmr8ScIvaQ==", + "version": "56.0.11", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-56.0.11.tgz", + "integrity": "sha512-Rt3U9Rqr6midz/sDeubFN5fefR0KzHpcEEgAUnV98XsLSSTnfzIxoC1blICb7pUUscKc8TtlVyn7/Gita2wnOQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "~7.10.4", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/json-file": "^10.0.8", + "@expo/config-plugins": "~56.0.11", + "@expo/config-types": "^56.0.7", + "@expo/json-file": "^10.2.0", + "@expo/require-utils": "^56.1.4", "deepmerge": "^4.3.1", "getenv": "^2.0.0", "glob": "^13.0.0", - "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.1" + "slugify": "^1.3.4" } }, "node_modules/@expo/config-plugins": { - "version": "54.0.4", - "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz", - "integrity": "sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q==", + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-56.0.12.tgz", + "integrity": "sha512-UKjPEOkvxBzTvjghmjUECURDoJLPJIFIevB0JQCe8l9Fg4yfy2fabU5LU3Kfrmmu1/8et/93bucCnABEmoxYEw==", "license": "MIT", "dependencies": { - "@expo/config-types": "^54.0.10", - "@expo/json-file": "~10.0.8", - "@expo/plist": "^0.4.8", + "@expo/config-types": "^56.0.7", + "@expo/json-file": "~10.2.0", + "@expo/plist": "^0.7.0", + "@expo/require-utils": "^56.1.4", "@expo/sdk-runtime-versions": "^1.0.0", "chalk": "^4.1.2", "debug": "^4.3.5", "getenv": "^2.0.0", "glob": "^13.0.0", - "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" } }, "node_modules/@expo/config-types": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-54.0.10.tgz", - "integrity": "sha512-/J16SC2an1LdtCZ67xhSkGXpALYUVUNyZws7v+PVsFZxClYehDSoKLqyRaGkpHlYrCc08bS0RF5E0JV6g50psA==", + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-56.0.7.tgz", + "integrity": "sha512-V7bxawNsNned/yMppAHdisIOxniZXgPKRWpIUiQOQBs45/A5MBd2gfDM4Ecq5gnbilnQUTaI6Zxn6JcW7L3TAA==", "license": "MIT" }, "node_modules/@expo/devcert": { @@ -2324,9 +1797,9 @@ } }, "node_modules/@expo/devtools": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-0.1.8.tgz", - "integrity": "sha512-SVLxbuanDjJPgc0sy3EfXUMLb/tXzp6XIHkhtPVmTWJAp+FOr6+5SeiCfJrCzZFet0Ifyke2vX3sFcKwEvCXwQ==", + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-56.0.2.tgz", + "integrity": "sha512-ANl4kPdbe0/HQYWkDEN79S6bQhI+i/ZCnPxuC853pPsB4svhINC7Ku9lmGOKPsUUWWnrHg1spkDGQBZ4sD6JxQ==", "license": "MIT", "dependencies": { "chalk": "^4.1.2" @@ -2345,25 +1818,33 @@ } }, "node_modules/@expo/env": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.0.11.tgz", - "integrity": "sha512-xV+ps6YCW7XIPVUwFVCRN2nox09dnRwy8uIjwHWTODu0zFw4kp4omnVkl0OOjuu2XOe7tdgAHxikrkJt9xB/7Q==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.3.1.tgz", + "integrity": "sha512-JvBdZa5OkTd+dGEtt3fLW9OF6RlIp9SNu9VyMSiWPV5szMDTSAYbX8Uj3cRvZcU1zzaRbqnTSsHk2AE8WXHfxQ==", "license": "MIT", "dependencies": { "chalk": "^4.0.0", "debug": "^4.3.4", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" } }, + "node_modules/@expo/expo-modules-macros-plugin": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.2.2.tgz", + "integrity": "sha512-4IMzPDIo/VOXREQjsJtliSfqYVZvfzU2SLFS/9sKMWF848S8CHx+e/E+Vf0TcMvpWCCKX5umyqxb13KJJ+YUzg==", + "license": "MIT" + }, "node_modules/@expo/fingerprint": { - "version": "0.15.5", - "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.15.5.tgz", - "integrity": "sha512-mdVoAMcux1WlM6kd1RoWiHRNqKqS+J6mKmWQ/BKgeh937S/fcW58EE68O6nc4KDXtWi3PBeNHskOFcgyIuD4hw==", + "version": "0.19.7", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.19.7.tgz", + "integrity": "sha512-Q04NyJE0E7qKGXepBjI8e0p983RrQGBWJcSICKyyLczsr5JhNuSmqw604aL7koaXG2ctrUL36qd332XiMS/s6w==", "license": "MIT", "dependencies": { - "@expo/spawn-async": "^1.7.2", + "@expo/env": "^2.3.1", + "@expo/spawn-async": "^1.8.0", "arg": "^5.0.2", "chalk": "^4.1.2", "debug": "^4.3.4", @@ -2371,7 +1852,6 @@ "glob": "^13.0.0", "ignore": "^5.3.1", "minimatch": "^10.2.2", - "p-limit": "^3.1.0", "resolve-from": "^5.0.0", "semver": "^7.6.0" }, @@ -2379,6 +1859,20 @@ "fingerprint": "bin/cli.js" } }, + "node_modules/@expo/fingerprint/node_modules/@expo/env": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.1.tgz", + "integrity": "sha512-3c9Mg9x0HmGPEsVrGAGyEDJsNUOZ55cZvZ47/HLmXh7MHV9Zv7My73wThklKrObaBBoMfE4YqpKjYKDRzojpjQ==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, "node_modules/@expo/fingerprint/node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2389,9 +1883,9 @@ } }, "node_modules/@expo/fingerprint/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2416,13 +1910,13 @@ } }, "node_modules/@expo/image-utils": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.8.14.tgz", - "integrity": "sha512-5Sn+jG4Cw+shC2wDMXoqSAJnvERbiwzHn05FpWtD5IBflfTIs5gUmjzwiGVyjOdlMSQhgRrw/AymPbmO9h9mpQ==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.10.2.tgz", + "integrity": "sha512-qQUGaacqXduoFTCUQAMceIWYzlKU0xX4/BKTyh8TdVj0uHv9/W3MfHOy5yXoOqBVrSccXk++Gm4D/NfS5HnCNA==", "license": "MIT", "dependencies": { - "@expo/require-utils": "^55.0.5", - "@expo/spawn-async": "^1.7.2", + "@expo/require-utils": "^56.1.4", + "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "getenv": "^2.0.0", "jimp-compact": "0.16.1", @@ -2430,93 +1924,140 @@ "semver": "^7.6.0" } }, + "node_modules/@expo/inline-modules": { + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.0.13.tgz", + "integrity": "sha512-26RllWesRmYsAAo70cRcR9DaqXPKJct9MIGxZteS+Tkg25ljOkFeG7fYEsSazZOLpAslKBiilqtZykVYrxSzCw==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~56.0.11" + } + }, "node_modules/@expo/json-file": { - "version": "10.0.14", - "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.0.14.tgz", - "integrity": "sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-10.2.0.tgz", + "integrity": "sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "json5": "^2.2.3" } }, - "node_modules/@expo/json-file/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/@expo/local-build-cache-provider": { + "version": "56.0.9", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-56.0.9.tgz", + "integrity": "sha512-VMJC5ul7dXPfD2OO16ObPi6ym4H8vlnCBUuCM/KeKh0OvLRub35X8OB2uYrFQ+vbWkPemAEOWvk0oOptpPxbzA==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" + "@expo/config": "~56.0.11", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/metro": { + "version": "56.0.0", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.0.tgz", + "integrity": "sha512-5gIgQHtEpjjvsjKfVtIv23a98LLRV0/y07PDShEwYSytAMlE3FSF8RHXqtHc1sUJL6dn7hnuIBpIbrLXXuVi0A==", + "license": "MIT", + "dependencies": { + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4" } }, - "node_modules/@expo/metro": { - "version": "54.2.0", - "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-54.2.0.tgz", - "integrity": "sha512-h68TNZPGsk6swMmLm9nRSnE2UXm48rWwgcbtAHVMikXvbxdS41NDHHeqg1rcQ9AbznDRp6SQVC2MVpDnsRKU1w==", - "license": "MIT", - "dependencies": { - "metro": "0.83.3", - "metro-babel-transformer": "0.83.3", - "metro-cache": "0.83.3", - "metro-cache-key": "0.83.3", - "metro-config": "0.83.3", - "metro-core": "0.83.3", - "metro-file-map": "0.83.3", - "metro-minify-terser": "0.83.3", - "metro-resolver": "0.83.3", - "metro-runtime": "0.83.3", - "metro-source-map": "0.83.3", - "metro-symbolicate": "0.83.3", - "metro-transform-plugins": "0.83.3", - "metro-transform-worker": "0.83.3" + "node_modules/@expo/metro-file-map": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-56.0.3.tgz", + "integrity": "sha512-5OGW3z8LgEYgMJOR7F3pC8llFLkb1fVqwAewbCl6S4Vkha8AFQMwOjT+9Wbka+V4rmpljpGqOnMhF4xZbD961w==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "fb-watchman": "^2.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" } }, "node_modules/@expo/osascript": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.4.3.tgz", - "integrity": "sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.7.0.tgz", + "integrity": "sha512-wKIXL8UtbuX4KwavPasIW3CUcgTbYfjzLcgUhjyKUAYDEqMaf6gmU1bqz3ffBPTokmX+G8/vFG1ZuI9etQWukA==", "license": "MIT", "dependencies": { - "@expo/spawn-async": "^1.7.2" + "@expo/spawn-async": "^1.8.0" }, "engines": { "node": ">=12" } }, "node_modules/@expo/package-manager": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.10.5.tgz", - "integrity": "sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.13.0.tgz", + "integrity": "sha512-s3W3eZafJDEyVL7W/jxj2Nz3eONKxSCU604S5xj8ijrVaRz83x0DnZznLf/UXQEI1w+FyibH68nHeQyk767b1A==", "license": "MIT", "dependencies": { - "@expo/json-file": "^10.0.14", - "@expo/spawn-async": "^1.7.2", + "@expo/json-file": "^11.0.0", + "@expo/spawn-async": "^1.8.0", "chalk": "^4.0.0", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", "resolve-workspace-root": "^2.0.0" } }, + "node_modules/@expo/package-manager/node_modules/@expo/json-file": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.0.tgz", + "integrity": "sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, "node_modules/@expo/plist": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.4.8.tgz", - "integrity": "sha512-pfNtErGGzzRwHP+5+RqswzPDKkZrx+Cli0mzjQaus1ZWFsog5ibL+nVT3NcporW51o8ggnt7x813vtRbPiyOrQ==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.7.0.tgz", + "integrity": "sha512-vrpryU1GoqSIRNqRB2D3IjXDmzNYfiQpEF6AH/xknlD7eiYmEDt3mb26V7cLcedcPG8PY/1xWHdBXVQJfEAh6Q==", "license": "MIT", "dependencies": { "@xmldom/xmldom": "^0.8.8", - "base64-js": "^1.2.3", + "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, + "node_modules/@expo/prebuild-config": { + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-56.0.19.tgz", + "integrity": "sha512-aP/7kGDPMmwY9C9cYMK4MfUiZk23KhN4AfnKz0eeRyg3Izw0OGdtzNDLIcchnqpDAwHsI0+LUHZhucGl1jQJ5A==", + "license": "MIT", + "dependencies": { + "@expo/config": "~56.0.11", + "@expo/config-plugins": "~56.0.12", + "@expo/config-types": "^56.0.7", + "@expo/image-utils": "^0.10.2", + "@expo/json-file": "^10.2.0", + "@react-native/normalize-colors": "0.85.3", + "debug": "^4.3.1", + "expo-modules-autolinking": "~56.0.19", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + } + }, "node_modules/@expo/require-utils": { - "version": "55.0.5", - "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-55.0.5.tgz", - "integrity": "sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==", + "version": "56.1.4", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-56.1.4.tgz", + "integrity": "sha512-IX7XXg9obnrH3ni0TClBbVKgqk0nT0bjTEPTBtbD+WgIeZ0hpcgwxMJH3j5uBsUqEAq1jqUAkJWrPcg7ZRSV7g==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", @@ -2524,7 +2065,7 @@ "@babel/plugin-transform-modules-commonjs": "^7.24.8" }, "peerDependencies": { - "typescript": "^5.0.0 || ^5.0.0-0" + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0" }, "peerDependenciesMeta": { "typescript": { @@ -2532,24 +2073,10 @@ } } }, - "node_modules/@expo/require-utils/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@expo/schema-utils": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-0.1.8.tgz", - "integrity": "sha512-9I6ZqvnAvKKDiO+ZF8BpQQFYWXOJvTAL5L/227RUbWG1OVZDInFifzCBiqAZ3b67NRfeAgpgvbA7rejsqhY62A==", + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.2.tgz", + "integrity": "sha512-WcOH1E6rwxRqNwBzPOVhd5GBbMlS04+5n+ZMdhdwKOg/Kt3suV+F8F6An+z8mUJ8eSuMM3HD9Sj09t7vc/Xjkw==", "license": "MIT" }, "node_modules/@expo/sdk-runtime-versions": { @@ -2559,12 +2086,12 @@ "license": "MIT" }, "node_modules/@expo/spawn-async": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.7.2.tgz", - "integrity": "sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3" + "cross-spawn": "^7.0.6" }, "engines": { "node": ">=12" @@ -2577,10 +2104,13 @@ "license": "MIT" }, "node_modules/@expo/ws-tunnel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-1.0.6.tgz", - "integrity": "sha512-nDRbLmSrJar7abvUjp3smDwH8HcbZcoOEa5jVPUv9/9CajgmWw20JNRwTuBRzWIWIkEJDkz20GoNA+tSwUqk0Q==", - "license": "MIT" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.0.tgz", + "integrity": "sha512-j+JfTRdCk820J9dU0sA2SqshQIKFOMo7ED84w9MJFcebfbNQgsLztEY/SABDkGnjatrW4xGqnUhVRxSBVyCkXw==", + "license": "MIT", + "peerDependencies": { + "ws": "^8.0.0" + } }, "node_modules/@expo/xcpretty": { "version": "4.4.4", @@ -2596,20 +2126,6 @@ "excpretty": "build/cli.js" } }, - "node_modules/@expo/xcpretty/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2676,18 +2192,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@isaacs/ttlcache": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", @@ -2697,158 +2201,6 @@ "node": ">=12" } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/create-cache-key-function": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", - "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jest/schemas": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", @@ -2861,32 +2213,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/@jest/types": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", @@ -2959,6 +2285,70 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.8.5", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz", + "integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-react-native": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-react-native/-/maplibre-react-native-11.3.4.tgz", + "integrity": "sha512-SgGFf+CWWNeTRBnBKcOKF6c7CiRhm+YgPNktg7HAzRsWoKeGMZy6u52STaTh4iP/2pwJygJPyiJJMkwzwW2Svg==", + "license": "MIT", + "dependencies": { + "@maplibre/maplibre-gl-style-spec": "24.8.5", + "@turf/distance": "^7.3.5", + "@turf/helpers": "^7.3.5", + "@turf/length": "^7.3.5", + "@turf/nearest-point-on-line": "^7.3.5" + }, + "peerDependencies": { + "@types/geojson": "^7946.0.0", + "@types/react": ">=19.1.0", + "expo": ">=54.0.0", + "react": ">=19.1.0", + "react-native": ">=0.80.0" + }, + "peerDependenciesMeta": { + "@types/geojson": { + "optional": true + }, + "@types/react": { + "optional": true + }, + "expo": { + "optional": true + } + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2995,163 +2385,68 @@ } }, "node_modules/@react-native/assets-registry": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.81.5.tgz", - "integrity": "sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.85.3.tgz", + "integrity": "sha512-u9ZiYP23vA2IFtdFQFmetzSmk6SM0xgKIoiOsr1hXNHjHaLhOm+/Ph1ud57wX6+Dbwdzx8coJgnzSKL3W21PCg==", "license": "MIT", "engines": { - "node": ">= 20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.81.5.tgz", - "integrity": "sha512-oF71cIH6je3fSLi6VPjjC3Sgyyn57JLHXs+mHWc9MoCiJJcM4nqsS5J38zv1XQ8d3zOW2JtHro+LF0tagj2bfQ==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz", + "integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.3", - "@react-native/codegen": "0.81.5" - }, - "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/babel-preset": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.81.5.tgz", - "integrity": "sha512-UoI/x/5tCmi+pZ3c1+Ypr1DaRMDLI3y+Q70pVLLVgrnC3DHsHRIbHcCHIeG/IJvoeFqFM2sTdhSOLJrf8lOPrA==", - "license": "MIT", - "dependencies": { - "@babel/core": "^7.25.2", - "@babel/plugin-proposal-export-default-from": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-default-from": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-transform-arrow-functions": "^7.24.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.4", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.25.4", - "@babel/plugin-transform-classes": "^7.25.4", - "@babel/plugin-transform-computed-properties": "^7.24.7", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-flow-strip-types": "^7.25.2", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-function-name": "^7.25.1", - "@babel/plugin-transform-literals": "^7.25.2", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-numeric-separator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.25.2", - "@babel/plugin-transform-react-jsx-self": "^7.24.7", - "@babel/plugin-transform-react-jsx-source": "^7.24.7", - "@babel/plugin-transform-regenerator": "^7.24.7", - "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/plugin-transform-shorthand-properties": "^7.24.7", - "@babel/plugin-transform-spread": "^7.24.7", - "@babel/plugin-transform-sticky-regex": "^7.24.7", - "@babel/plugin-transform-typescript": "^7.25.2", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/template": "^7.25.0", - "@react-native/babel-plugin-codegen": "0.81.5", - "babel-plugin-syntax-hermes-parser": "0.29.1", - "babel-plugin-transform-flow-enums": "^0.0.2", - "react-refresh": "^0.14.0" + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.85.3" }, "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@babel/core": "*" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/codegen": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.81.5.tgz", - "integrity": "sha512-a2TDA03Up8lpSa9sh5VRGCQDXgCTOyDOFH+aqyinxp1HChG8uk89/G+nkJ9FPd0rqgi25eCTR16TWdS3b+fA6g==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", + "integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", - "@babel/parser": "^7.25.3", - "glob": "^7.1.1", - "hermes-parser": "0.29.1", - "invariant": "^2.2.4", - "nullthrows": "^1.1.1", - "yargs": "^17.6.2" - }, - "engines": { - "node": ">= 20.19.4" - }, - "peerDependencies": { - "@babel/core": "*" - } - }, - "node_modules/@react-native/codegen/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@react-native/codegen/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/@react-native/codegen/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" + "@babel/parser": "^7.29.0", + "hermes-parser": "0.33.3", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" } }, "node_modules/@react-native/community-cli-plugin": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.81.5.tgz", - "integrity": "sha512-yWRlmEOtcyvSZ4+OvqPabt+NS36vg0K/WADTQLhrYrm9qdZSuXmq8PmdJWz/68wAqKQ+4KTILiq2kjRQwnyhQw==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.85.3.tgz", + "integrity": "sha512-fs85dmbIqNmtzEixDb0g+q6R3Vt4H9eAt8/inIZdDKfjN76+sUJA2r1nxODQ76bU23MrIbz8sI7KFBPaWk/zQw==", "license": "MIT", "dependencies": { - "@react-native/dev-middleware": "0.81.5", + "@react-native/dev-middleware": "0.85.3", "debug": "^4.4.0", "invariant": "^2.2.4", - "metro": "^0.83.1", - "metro-config": "^0.83.1", - "metro-core": "^0.83.1", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", "semver": "^7.1.3" }, "engines": { - "node": ">= 20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" }, "peerDependencies": { "@react-native-community/cli": "*", - "@react-native/metro-config": "*" + "@react-native/metro-config": "0.85.3" }, "peerDependenciesMeta": { "@react-native-community/cli": { @@ -3163,67 +2458,73 @@ } }, "node_modules/@react-native/debugger-frontend": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.81.5.tgz", - "integrity": "sha512-bnd9FSdWKx2ncklOetCgrlwqSGhMHP2zOxObJbOWXoj7GHEmih4MKarBo5/a8gX8EfA1EwRATdfNBQ81DY+h+w==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.85.3.tgz", + "integrity": "sha512-uAu7rM5o/Np1zgp6fi5zM1sP1aB8DcS7DdOLcj/TkSutOAjkMqqd2lWt1/+3S7qXexRHVK5XcP+o3VXo4L/V0A==", "license": "BSD-3-Clause", "engines": { - "node": ">= 20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.85.3.tgz", + "integrity": "sha512-/jRAaT9boiCttIcEwS02WPwYkUihqsjSaK/TMtHz05vT6uMgac9PaQt5kzBQLIABv5aEIa5gtrMmKVz49MjkjQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/dev-middleware": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.81.5.tgz", - "integrity": "sha512-WfPfZzboYgo/TUtysuD5xyANzzfka8Ebni6RIb2wDxhb56ERi7qDrE4xGhtPsjCL4pQBXSVxyIlCy0d8I6EgGA==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.85.3.tgz", + "integrity": "sha512-JYzBiT4A8w+KQt+dOD5v+ti+tDrGoPnsSTuApq3Ls4RB5sfWbDlYMyz3dbc8qBIHz9tv0sQ5+eOu6Xwqzr5AQA==", "license": "MIT", "dependencies": { "@isaacs/ttlcache": "^1.4.1", - "@react-native/debugger-frontend": "0.81.5", + "@react-native/debugger-frontend": "0.85.3", + "@react-native/debugger-shell": "0.85.3", "chrome-launcher": "^0.15.2", - "chromium-edge-launcher": "^0.2.0", + "chromium-edge-launcher": "^0.3.0", "connect": "^3.6.5", "debug": "^4.4.0", "invariant": "^2.2.4", "nullthrows": "^1.1.1", "open": "^7.0.3", "serve-static": "^1.16.2", - "ws": "^6.2.3" + "ws": "^7.5.10" }, "engines": { - "node": ">= 20.19.4" - } - }, - "node_modules/@react-native/dev-middleware/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "license": "MIT", - "dependencies": { - "async-limiter": "~1.0.0" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/gradle-plugin": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.81.5.tgz", - "integrity": "sha512-hORRlNBj+ReNMLo9jme3yQ6JQf4GZpVEBLxmTXGGlIL78MAezDZr5/uq9dwElSbcGmLEgeiax6e174Fie6qPLg==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.85.3.tgz", + "integrity": "sha512-39dY2j50Q1pntejzwt3XL7vwXtrj8jcIfHq6E+gyu3jzYxZJVvMkMutQ39vSg6zinIQOX36oQDhidXUbCXzgoA==", "license": "MIT", "engines": { - "node": ">= 20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/js-polyfills": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.5.tgz", - "integrity": "sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.85.3.tgz", + "integrity": "sha512-U2+aMshIXf1uFn77tpBb/xhHWB9vkVrMpt7kkucAugF8hJKYTDGB587X7WwelHduK2KBfhl4giSv0rzZGoef9A==", "license": "MIT", "engines": { - "node": ">= 20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/normalize-colors": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz", - "integrity": "sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.85.3.tgz", + "integrity": "sha512-hj0PScZEhIbcOvQV5yMKX3ha4XEIOy/SVE1Rrpp0beW0dpNLOgSC7KDxGewmDnIHK9YdQUXGY9eMEfShUMIaZw==", "license": "MIT" }, "node_modules/@rtsao/scc": { @@ -3239,76 +2540,112 @@ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", "license": "MIT" }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "license": "BSD-3-Clause", + "node_modules/@turf/distance": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/distance/-/distance-7.3.5.tgz", + "integrity": "sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA==", + "license": "MIT", "dependencies": { - "type-detect": "4.0.8" + "@turf/helpers": "7.3.5", + "@turf/invariant": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "license": "BSD-3-Clause", + "node_modules/@turf/helpers": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-7.3.5.tgz", + "integrity": "sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==", + "license": "MIT", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, + "node_modules/@turf/invariant": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-7.3.5.tgz", + "integrity": "sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg==", "license": "MIT", - "optional": true, "dependencies": { - "tslib": "^2.4.0" + "@turf/helpers": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@turf/length": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/length/-/length-7.3.5.tgz", + "integrity": "sha512-Bi+vEP54wt1ly3BRcCOP0nd2kGTYEhGk6haQxTpkrqr3XtmqDh8c3NowSgseN2cegIZRjwCOEC8eSsZ0JemJdA==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "@turf/distance": "7.3.5", + "@turf/helpers": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@turf/meta": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/meta/-/meta-7.3.5.tgz", + "integrity": "sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.0.0" + "@turf/helpers": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@turf/nearest-point-on-line": { + "version": "7.3.5", + "resolved": "https://registry.npmjs.org/@turf/nearest-point-on-line/-/nearest-point-on-line-7.3.5.tgz", + "integrity": "sha512-MZn6OkEFZpjS6BNUANfqiHMIbQSivu7TNji3a+OAIrnPJ71vp8cbz0N2aVEa5M7I8ipvxoxAPIV3eqg3h280Vg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "@turf/distance": "7.3.5", + "@turf/helpers": "7.3.5", + "@turf/invariant": "7.3.5", + "@turf/meta": "7.3.5", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + }, + "funding": { + "url": "https://opencollective.com/turf" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/types": "^7.28.2" + "tslib": "^2.4.0" } }, + "node_modules/@types/emscripten": { + "version": "1.41.5", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz", + "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3316,14 +2653,11 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -3364,30 +2698,24 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, "node_modules/@types/react": { - "version": "19.1.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", - "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", "dependencies": { - "csstype": "^3.0.2" + "csstype": "^3.2.2" } }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "license": "MIT" - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -3984,29 +3312,6 @@ "win32" ] }, - "node_modules/@urql/core": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@urql/core/-/core-5.2.0.tgz", - "integrity": "sha512-/n0ieD0mvvDnVAXEQgX/7qJiVcvYvNkOHeBvkwtylfjydar123caCXcl58PXFY11oU1oquJocVXHxLAbtv4x1A==", - "license": "MIT", - "dependencies": { - "@0no-co/graphql.web": "^1.0.13", - "wonka": "^6.3.2" - } - }, - "node_modules/@urql/exchange-retry": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@urql/exchange-retry/-/exchange-retry-1.3.2.tgz", - "integrity": "sha512-TQMCz2pFJMfpNxmSfX1VSfTjwUIFx/mL+p1bnfM1xjjdla7Z+KnGMW/EhFbpckp3LyWAH4PgOsMwOMnIN+MBFg==", - "license": "MIT", - "dependencies": { - "@urql/core": "^5.1.2", - "wonka": "^6.3.2" - }, - "peerDependencies": { - "@urql/core": "^5.0.0" - } - }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -4072,6 +3377,18 @@ "node": ">= 14" } }, + "node_modules/agent-cli-detector": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.2.tgz", + "integrity": "sha512-qdZ/9JFORtTKJNhT/IczMeEfEUbUU0K5umYeiIQHX+AjHs+Y9SXVzSgaYlpZeyNMrvuh2HpZiOTpvS57iPfBkQ==", + "license": "MIT", + "bin": { + "agent-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4146,25 +3463,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -4353,12 +3651,6 @@ "node": ">= 0.4" } }, - "node_modules/async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "license": "MIT" - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -4375,58 +3667,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.17", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", @@ -4491,27 +3731,12 @@ "license": "MIT" }, "node_modules/babel-plugin-syntax-hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz", - "integrity": "sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA==", - "license": "MIT", - "dependencies": { - "hermes-parser": "0.29.1" - } - }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/babel-plugin-syntax-hermes-parser/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.33.3.tgz", + "integrity": "sha512-/Z9xYdaJ1lC0pT9do6TqCqhOSLfZ5Ot8D5za1p+feEfWYupCOfGbhhEXN9r2ZgJtDNUNRw/Z+T2CvAGKBqtqWA==", "license": "MIT", "dependencies": { - "hermes-estree": "0.29.1" + "hermes-parser": "0.33.3" } }, "node_modules/babel-plugin-transform-flow-enums": { @@ -4523,54 +3748,28 @@ "@babel/plugin-syntax-flow": "^7.12.1" } }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, + "node_modules/barcode-detector": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/barcode-detector/-/barcode-detector-3.2.0.tgz", + "integrity": "sha512-MrT5TT058ptG5YB157pHLfXKVpp0BKEfQBOb8QvzTbatzmLDu85JJ0Gd/sCYwbwdwStJvxsYflrSN6D6E4Ndyw==", + "license": "MIT", + "dependencies": { + "zxing-wasm": "3.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4603,35 +3802,6 @@ "node": ">=6.0.0" } }, - "node_modules/better-opn": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", - "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", - "license": "MIT", - "dependencies": { - "open": "^8.0.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/better-opn/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/big-integer": { "version": "1.6.52", "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", @@ -4666,6 +3836,7 @@ "version": "1.1.14", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4726,30 +3897,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4873,15 +4020,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, "node_modules/chrome-launcher": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", @@ -4901,17 +4039,16 @@ } }, "node_modules/chromium-edge-launcher": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz", - "integrity": "sha512-JfJjUnq25y9yg4FABRRVPmBGWPZZi+AQXT4mxupb67766/0UlhG8PAZCz6xzEMXTbW3CsSoE8PcCWA49n35mKg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", "license": "Apache-2.0", "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^1.0.0", - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" + "mkdirp": "^1.0.4" } }, "node_modules/ci-info": { @@ -5052,6 +4189,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/connect": { @@ -5195,15 +4333,6 @@ } } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -5250,15 +4379,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", @@ -5305,6 +4425,12 @@ "node": ">=8" } }, + "node_modules/dnssd-advertise": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", + "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5318,33 +4444,6 @@ "node": ">=0.10.0" } }, - "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -5387,15 +4486,6 @@ "node": ">= 0.8" } }, - "node_modules/env-editor": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", - "integrity": "sha512-ObFo8v4rQJAE59M69QzwloxPZtd33TpYEIjtKD1rrFDcM1Gd7IkDxEBU+HriziN6HSHQnBJi8Dmy+JWkav5HKA==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -5582,9 +4672,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -5595,32 +4685,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -5711,19 +4801,19 @@ } }, "node_modules/eslint-config-expo": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-expo/-/eslint-config-expo-10.0.0.tgz", - "integrity": "sha512-/XC/DvniUWTzU7Ypb/cLDhDD4DXqEio4lug1ObD/oQ9Hcx3OVOR8Mkp4u6U4iGoZSJyIQmIk3WVHe/P1NYUXKw==", + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/eslint-config-expo/-/eslint-config-expo-56.0.4.tgz", + "integrity": "sha512-1OD7rJMxCchKHxq+U+OQsAxVtzAxeUb9875g6+15KsSD9fqKTgq7DEEWYwunzU9r9E8kYJ+mh7+j86vF9m9NMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "^8.18.2", - "@typescript-eslint/parser": "^8.18.2", + "@typescript-eslint/eslint-plugin": "^8.59.0", + "@typescript-eslint/parser": "^8.59.0", "eslint-import-resolver-typescript": "^3.6.3", - "eslint-plugin-expo": "^1.0.0", + "eslint-plugin-expo": "^1.0.1", "eslint-plugin-import": "^2.30.0", "eslint-plugin-react": "^7.37.3", - "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-hooks": "^7.0.0", "globals": "^16.0.0" }, "peerDependencies": { @@ -5829,9 +4919,9 @@ } }, "node_modules/eslint-plugin-expo": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-expo/-/eslint-plugin-expo-1.0.0.tgz", - "integrity": "sha512-qLtunR+cNFtC+jwYCBia5c/PJurMjSLMOV78KrEOyQK02ohZapU4dCFFnS2hfrJuw0zxfsjVkjqg3QBqi933QA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-expo/-/eslint-plugin-expo-1.0.3.tgz", + "integrity": "sha512-C1v9NPvpDET36+7Klpp/+53Jl+VzOfpbDxpKtL/pAPhCDwTX0kW6Swo425PT0uc4AMT5jpQbB7hSKFjKOGMl4A==", "dev": true, "license": "MIT", "dependencies": { @@ -5934,16 +5024,40 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react-hooks/node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" } }, "node_modules/eslint-plugin-react/node_modules/semver": { @@ -6004,19 +5118,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/esquery": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", @@ -6082,32 +5183,34 @@ } }, "node_modules/expo": { - "version": "54.0.34", - "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.34.tgz", - "integrity": "sha512-XkVHguZZDC8BcTQxHAd14/TQFbDp1Wt0Z/KApO9t68Ll5A127hLCPzU+a9gytfCIiyL/V1IpF1vIcOLKEVAoNQ==", + "version": "56.0.15", + "resolved": "https://registry.npmjs.org/expo/-/expo-56.0.15.tgz", + "integrity": "sha512-Tnas9Sq1fDY865rhSQ4266Kd4GULEUyBEBEchbNLQsiY6U+AAt4MgCKJUsRvUkNHdaZwnGzexy6hdnOjsgvNcA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "54.0.24", - "@expo/config": "~12.0.13", - "@expo/config-plugins": "~54.0.4", - "@expo/devtools": "0.1.8", - "@expo/fingerprint": "0.15.5", - "@expo/metro": "~54.2.0", - "@expo/metro-config": "54.0.15", - "@expo/vector-icons": "^15.0.3", + "@expo/cli": "^56.1.19", + "@expo/config": "~56.0.11", + "@expo/config-plugins": "~56.0.12", + "@expo/devtools": "~56.0.2", + "@expo/dom-webview": "~56.0.6", + "@expo/fingerprint": "^0.19.7", + "@expo/local-build-cache-provider": "^56.0.9", + "@expo/log-box": "^56.0.14", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~56.0.16", "@ungap/structured-clone": "^1.3.0", - "babel-preset-expo": "~54.0.10", - "expo-asset": "~12.0.13", - "expo-constants": "~18.0.13", - "expo-file-system": "~19.0.22", - "expo-font": "~14.0.11", - "expo-keep-awake": "~15.0.8", - "expo-modules-autolinking": "3.0.25", - "expo-modules-core": "3.0.30", + "babel-preset-expo": "~56.0.17", + "expo-asset": "~56.0.19", + "expo-constants": "~56.0.20", + "expo-file-system": "~56.0.8", + "expo-font": "~56.0.7", + "expo-keep-awake": "~56.0.3", + "expo-modules-autolinking": "~56.0.19", + "expo-modules-core": "~56.0.20", "pretty-format": "^29.7.0", "react-refresh": "^0.14.2", - "whatwg-url-without-unicode": "8.0.0-3" + "whatwg-url-minimum": "^0.1.2" }, "bin": { "expo": "bin/cli", @@ -6118,7 +5221,9 @@ "@expo/dom-webview": "*", "@expo/metro-runtime": "*", "react": "*", + "react-dom": "*", "react-native": "*", + "react-native-web": "*", "react-native-webview": "*" }, "peerDependenciesMeta": { @@ -6128,18 +5233,33 @@ "@expo/metro-runtime": { "optional": true }, + "react-dom": { + "optional": true + }, + "react-native-web": { + "optional": true + }, "react-native-webview": { "optional": true } } }, + "node_modules/expo-application": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz", + "integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-camera": { - "version": "17.0.10", - "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-17.0.10.tgz", - "integrity": "sha512-w1RBw83mAGVk4BPPwNrCZyFop0VLiVSRE3c2V9onWbdFwonpRhzmB4drygG8YOUTl1H3wQvALJHyMPTbgsK1Jg==", + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo-camera/-/expo-camera-56.0.8.tgz", + "integrity": "sha512-UDOpUUMisFRmCv1XQV1MJCKGAH2CsIC1Rs6P9Bbc6JLVmbxEKAd5dK68y6cScOdWURxVfJ0PRcjYnSuc8ayyIQ==", "license": "MIT", "dependencies": { - "invariant": "^2.2.4" + "barcode-detector": "^3.0.0" }, "peerDependencies": { "expo": "*", @@ -6153,162 +5273,278 @@ } } }, + "node_modules/expo-constants": { + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-56.0.20.tgz", + "integrity": "sha512-4HgoVvUiMvcqujr/CUj7L1tumLWj8WX0PLM77OriDPoaJrMEwUHd841m4o4IoUyMPVT8XTiTiPFwJtGali8+9Q==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.3.1" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-client": { + "version": "56.0.22", + "resolved": "https://registry.npmjs.org/expo-dev-client/-/expo-dev-client-56.0.22.tgz", + "integrity": "sha512-LKhgIlMu8DkmhTtfurpX9YMPsnv4QI1hCcDZqcFn+jlB+s6yE1THB9UWZ+BVtef8kYMzsw+BH3ClXvNkRd0vXw==", + "license": "MIT", + "dependencies": { + "expo-dev-launcher": "~56.0.23", + "expo-dev-menu": "~56.0.19", + "expo-dev-menu-interface": "~56.0.0", + "expo-manifests": "~56.0.4", + "expo-updates-interface": "~56.0.1" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-dev-launcher": { + "version": "56.0.23", + "resolved": "https://registry.npmjs.org/expo-dev-launcher/-/expo-dev-launcher-56.0.23.tgz", + "integrity": "sha512-OKgLAn6m15Lu+Qyg8WA0HPurFR9pxhsrHUvvafv8pTZHspB0BbOvBwhOJdxgiMfR2LHq4032cnMf8164Vzqeng==", + "license": "MIT", + "dependencies": { + "@expo/schema-utils": "^56.0.2", + "expo-dev-menu": "~56.0.19", + "expo-manifests": "~56.0.4" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu": { + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-dev-menu/-/expo-dev-menu-56.0.19.tgz", + "integrity": "sha512-sm6dfnkq3lgbn39Kd4yjFFm7UQAZqm6Hn3OpjagGtWBoWByQae3nKh1MF5K4Peh172TqUPJTOhjRasnZSkUkOQ==", + "license": "MIT", + "dependencies": { + "expo-dev-menu-interface": "~56.0.0" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-dev-menu-interface": { + "version": "56.0.1", + "resolved": "https://registry.npmjs.org/expo-dev-menu-interface/-/expo-dev-menu-interface-56.0.1.tgz", + "integrity": "sha512-odATx0ZL/Kis10sKSBiKiGQxAB6coSi/KQtKcMhnQVNno6FkRh5/4e5BqcEvpq2rNMTiQp4ytNAQHtdwbPXvGA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-image-loader": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-6.0.0.tgz", - "integrity": "sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==", + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz", + "integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-image-picker": { - "version": "17.0.11", - "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-17.0.11.tgz", - "integrity": "sha512-/apkoyukDvsCHHb9fzP+F34A1uQqSzUtYH/2P/xJACNEwq+mwEXjXvVU8bzlJq6ih0Qo1+tpVivIa7B9kYSwOQ==", + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.20.tgz", + "integrity": "sha512-75xP7WA7Z8b4+5wYCu1cISg90Cu9CowneGMFuAgmmpNxCXLnBbrJXO6XqYIM69KAjEv7PApjgQF3h6RdBqus4w==", "license": "MIT", "dependencies": { - "expo-image-loader": "~6.0.0" + "expo-image-loader": "~56.0.3" }, "peerDependencies": { "expo": "*" } }, + "node_modules/expo-json-utils": { + "version": "56.0.0", + "resolved": "https://registry.npmjs.org/expo-json-utils/-/expo-json-utils-56.0.0.tgz", + "integrity": "sha512-lUqyv9aIGDbYTQ5Nux2FnH2/Dz0w5uJ8Pr080eS0StXi2jr5OmuMNErpzUnpfnYOU55xKotd4AHv68PfV/ludg==", + "license": "MIT" + }, "node_modules/expo-location": { - "version": "19.0.8", - "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-19.0.8.tgz", - "integrity": "sha512-H/FI75VuJ1coodJbbMu82pf+Zjess8X8Xkiv9Bv58ZgPKS/2ztjC1YO1/XMcGz7+s9DrbLuMIw22dFuP4HqneA==", + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.20.tgz", + "integrity": "sha512-yQhkHgjW47dz7cbRg1UcsGWuWNQ1Yh+nEqmZkAdFdDVw6PXuxV63c8VNyXBzvuOgNuBd2TSstSuOb0t3tFJJZg==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.10.2" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-manifests": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-manifests/-/expo-manifests-56.0.4.tgz", + "integrity": "sha512-Fokawl2UkiExIF0bqGoblRFA8lYpROVD+EpvDwSW4LgqQyPwNua1gLSgHZjdl5GsVugfRMMWE3LHaibDyX93hw==", "license": "MIT", + "dependencies": { + "expo-json-utils": "~56.0.0" + }, "peerDependencies": { "expo": "*" } }, "node_modules/expo-modules-autolinking": { - "version": "3.0.25", - "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-3.0.25.tgz", - "integrity": "sha512-YmHWctJlwvOuLZccg3cOXvSiXVJrPMKl7g2YR0YHWoGL9v2RvcmgaPJWPSLVW+voNEgEPsbo5UmUrAqbnYcBeg==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.19.tgz", + "integrity": "sha512-ztzTzS21fbq4oJQItSgT+WWJMZXGsC0GdX/cDN/GBhCTPiZz8foHkVa14DiLa5CWd7pEZ9EeSMdHDcAEc2dZ5Q==", "license": "MIT", "dependencies": { - "@expo/spawn-async": "^1.7.2", + "@expo/require-utils": "^56.1.4", + "@expo/spawn-async": "^1.8.0", "chalk": "^4.1.0", - "commander": "^7.2.0", - "require-from-string": "^2.0.2", - "resolve-from": "^5.0.0" + "commander": "^7.2.0" }, "bin": { "expo-modules-autolinking": "bin/expo-modules-autolinking.js" } }, "node_modules/expo-modules-core": { - "version": "3.0.30", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-3.0.30.tgz", - "integrity": "sha512-a6IrpAn/Jbmwxi9L+hMmXKpNqnkUpoF7WHOpn02rVLyax2J0gB1vvCVE5rNydplEnt41Q6WxQwvcOjZaIkcSUg==", + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.20.tgz", + "integrity": "sha512-Xagwt/gC6sV1jiSnlFrYxLooZr90adLrpV64YlkXZjgZ6Kot89FF62e457RyejLtyDm2pOdy6FZphF1ReyK2Jw==", "license": "MIT", "dependencies": { + "@expo/expo-modules-macros-plugin": "0.2.2", + "expo-modules-jsi": "~56.0.12", "invariant": "^2.2.4" }, "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": "^0.7.4 || ^0.8.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/expo-modules-jsi": { + "version": "56.0.12", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-56.0.12.tgz", + "integrity": "sha512-OnXiNbXzYybZPh5QnJyIIwQjQfN7PP4Di/2Hz0xQHKLgQmCfn/zAQziOjbUXVeL3cAyZ8+uDnTDYmDAc3B2qhQ==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/expo-notifications": { + "version": "56.0.20", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-56.0.20.tgz", + "integrity": "sha512-+LsF3O51IDoIk68xxp9q9sSwNDlZWWsykWouQ+jCfXZOa0mi86elRBqWV4IXN4YOTGbx9zYvnIzkga0F0Qar/Q==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.10.2", + "abort-controller": "^3.0.0", + "badgin": "^1.1.5", + "expo-application": "~56.0.3", + "expo-constants": "~56.0.20" + }, + "peerDependencies": { + "expo": "*", "react": "*", "react-native": "*" } }, "node_modules/expo-secure-store": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", - "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-56.0.4.tgz", + "integrity": "sha512-hjEi/gmpdFFJ9lYbdp3k3p/WchV7Gi0Qt8jt/m/0WJadqQrskafHAlDxbZkII1cN3Yd7zp9Lvkeq3UfGhSwirQ==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-server": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.6.tgz", - "integrity": "sha512-vb5TBtskvEdzYuW79lATXutOEBfW5m6U4EFpNjCVZTnI7S//SAsLQkYEpn+EDfn84m6VQfzSGkIVR6YPaScKFA==", + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-56.0.5.tgz", + "integrity": "sha512-SmM2p2g3Jrktpiazcst+OxhjSzOHXKAY4BPURHYHXvApzzoybMmrNF4IEZ8DKZ145BhSe4ydAmlEFCRTsdtgUQ==", "license": "MIT", "engines": { "node": ">=20.16.0" } }, "node_modules/expo-speech": { - "version": "14.0.8", - "resolved": "https://registry.npmjs.org/expo-speech/-/expo-speech-14.0.8.tgz", - "integrity": "sha512-UjBFCFv58nutlLw92L7kUS0ZjbOOfaTdiEv/HbjvMrT6BfldoOLLBZbaEcEhDdZK36NY/kass0Kzxk+co6vxSQ==", + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-speech/-/expo-speech-56.0.3.tgz", + "integrity": "sha512-NkO7RX+BgJCALrjL/6C9AsyenkmJD9EBDIKAQmSN6BLrcXxwZnyQ9TSo/ZTSDSY9Y28aStXHfEBQogt2qUrP9w==", "license": "MIT", "peerDependencies": { "expo": "*" } }, "node_modules/expo-status-bar": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-3.0.9.tgz", - "integrity": "sha512-xyYyVg6V1/SSOZWh4Ni3U129XHCnFHBTcUo0dhWtFDrZbNp/duw5AGsQfb2sVeU0gxWHXSY1+5F0jnKYC7WuOw==", + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-56.0.4.tgz", + "integrity": "sha512-IGs/fDfkHXofy2ZQrGiXayhFK04HB85FZXorhcEhDZEcqASKgSqpak+HwUtAaR0MeTJwWyHNF7I6VmVbbp8EcA==", "license": "MIT", - "dependencies": { - "react-native-is-edge-to-edge": "^1.2.1" - }, "peerDependencies": { + "expo": "*", "react": "*", "react-native": "*" } }, "node_modules/expo-task-manager": { - "version": "14.0.9", - "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-14.0.9.tgz", - "integrity": "sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA==", + "version": "56.0.21", + "resolved": "https://registry.npmjs.org/expo-task-manager/-/expo-task-manager-56.0.21.tgz", + "integrity": "sha512-yo0F2bnowFn/YbOidyuDTINbAxg/amFCL+XuCElFQEoK9/KsBJ5bwJbiPJl+rHP9ruUw1OMRuHieYjMCrus5QQ==", "license": "MIT", "dependencies": { - "unimodules-app-loader": "~6.0.8" + "unimodules-app-loader": "~56.0.0" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, - "node_modules/expo/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/expo-updates-interface": { + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-56.0.2.tgz", + "integrity": "sha512-eWTwSZ9y8vrULG2oBn2TQSSIwBGSq/TxGJ3jY6tuVS2FWH/ASRIiKs3zkUZTRoC3ZuV2alz0mUClYV7nNrFx8g==", "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" + "peerDependencies": { + "expo": "*" } }, "node_modules/expo/node_modules/@expo/cli": { - "version": "54.0.24", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.24.tgz", - "integrity": "sha512-5xse1bEgnVUBhOrtttc6xTNJVvjyTRavpzuF0/0nuj+312vfSbk7EiRbG+xJ2pW/iZxnhLPJkFCrPYG0nmheAQ==", + "version": "56.1.19", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.19.tgz", + "integrity": "sha512-k+oipwbu83WFnACtybvGZvDbbItarY4lfjkKgP9F5lml2ilvL/VvzsPhbhT7nRyFB3zPJq9SbNNKa45VDLoihA==", "license": "MIT", "dependencies": { - "@0no-co/graphql.web": "^1.0.8", "@expo/code-signing-certificates": "^0.0.6", - "@expo/config": "~12.0.13", - "@expo/config-plugins": "~54.0.4", + "@expo/config": "~56.0.11", + "@expo/config-plugins": "~56.0.12", "@expo/devcert": "^1.2.1", - "@expo/env": "~2.0.8", - "@expo/image-utils": "^0.8.8", - "@expo/json-file": "^10.0.8", - "@expo/metro": "~54.2.0", - "@expo/metro-config": "~54.0.15", - "@expo/osascript": "^2.3.8", - "@expo/package-manager": "^1.9.10", - "@expo/plist": "^0.4.8", - "@expo/prebuild-config": "^54.0.8", - "@expo/schema-utils": "^0.1.8", - "@expo/spawn-async": "^1.7.2", - "@expo/ws-tunnel": "^1.0.1", - "@expo/xcpretty": "^4.3.0", - "@react-native/dev-middleware": "0.81.5", - "@urql/core": "^5.0.6", - "@urql/exchange-retry": "^1.3.0", + "@expo/env": "~2.3.1", + "@expo/image-utils": "^0.10.2", + "@expo/inline-modules": "^0.0.13", + "@expo/json-file": "^10.2.0", + "@expo/log-box": "^56.0.14", + "@expo/metro": "~56.0.0", + "@expo/metro-config": "~56.0.16", + "@expo/metro-file-map": "^56.0.3", + "@expo/osascript": "^2.6.0", + "@expo/package-manager": "^1.12.1", + "@expo/plist": "^0.7.0", + "@expo/prebuild-config": "^56.0.19", + "@expo/require-utils": "^56.1.4", + "@expo/router-server": "^56.0.16", + "@expo/schema-utils": "^56.0.2", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.85.3", "accepts": "^1.3.8", + "agent-cli-detector": "^0.1.2", "arg": "^5.0.2", - "better-opn": "~3.0.2", "bplist-creator": "0.1.0", "bplist-parser": "^0.3.1", "chalk": "^4.0.0", @@ -6316,41 +5552,34 @@ "compression": "^1.7.4", "connect": "^3.7.0", "debug": "^4.3.4", - "env-editor": "^0.4.1", - "expo-server": "^1.0.6", - "freeport-async": "^2.0.0", + "dnssd-advertise": "^1.1.6", + "expo-server": "^56.0.5", + "fetch-nodeshim": "^0.4.10", "getenv": "^2.0.0", "glob": "^13.0.0", "lan-network": "^0.2.1", - "minimatch": "^9.0.0", + "multitars": "^1.0.0", "node-forge": "^1.3.3", "npm-package-arg": "^11.0.0", "ora": "^3.4.0", - "picomatch": "^4.0.3", - "pretty-bytes": "^5.6.0", + "picomatch": "^4.0.4", "pretty-format": "^29.7.0", "progress": "^2.0.3", "prompts": "^2.3.2", - "qrcode-terminal": "0.11.0", - "require-from-string": "^2.0.2", - "requireg": "^0.2.2", - "resolve": "^1.22.2", "resolve-from": "^5.0.0", - "resolve.exports": "^2.0.3", "semver": "^7.6.0", "send": "^0.19.0", "slugify": "^1.3.4", - "source-map-support": "~0.5.21", "stacktrace-parser": "^0.1.10", "structured-headers": "^0.4.1", - "tar": "^7.5.2", "terminal-link": "^2.1.1", - "undici": "^6.18.2", + "toqr": "^0.1.1", "wrap-ansi": "^7.0.0", - "ws": "^8.12.1" + "ws": "^8.12.1", + "zod": "^3.25.76" }, "bin": { - "expo-internal": "build/bin/cli" + "expo-internal": "main.js" }, "peerDependencies": { "expo": "*", @@ -6366,53 +5595,96 @@ } } }, - "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/prebuild-config": { - "version": "54.0.8", - "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-54.0.8.tgz", - "integrity": "sha512-EA7N4dloty2t5Rde+HP0IEE+nkAQiu4A/+QGZGT9mFnZ5KKjPPkqSyYcRvP5bhQE10D+tvz6X0ngZpulbMdbsg==", + "node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": { + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.16.tgz", + "integrity": "sha512-df5BI5GFnSKqAhuALTPIbuHisd4CUhuYam7pwF9Dt1D6BinPctsteWWDm/sEGXdLVhsub4NPI7tvUHo/HWL7aA==", "license": "MIT", "dependencies": { - "@expo/config": "~12.0.13", - "@expo/config-plugins": "~54.0.4", - "@expo/config-types": "^54.0.10", - "@expo/image-utils": "^0.8.8", - "@expo/json-file": "^10.0.8", - "@react-native/normalize-colors": "0.81.5", - "debug": "^4.3.1", - "resolve-from": "^5.0.0", - "semver": "^7.6.0", - "xml2js": "0.6.0" + "debug": "^4.3.4" }, "peerDependencies": { - "expo": "*" + "@expo/metro-runtime": "^56.0.16", + "expo": "*", + "expo-constants": "^56.0.20", + "expo-font": "^56.0.7", + "expo-router": "*", + "expo-server": "^56.0.5", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/expo/node_modules/@expo/dom-webview": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-56.0.6.tgz", + "integrity": "sha512-DQY3Tj5nrPbpIfEQiD9xbyEFTu25yEORa02Eyzl5rXszDzxDT4VkZDHkyErNQOyE1qjublJHNFhE5bDm4tRfqA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo/node_modules/@expo/log-box": { + "version": "56.0.14", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-56.0.14.tgz", + "integrity": "sha512-3Eadcxz0J2NESG2iKe8DnAggsRCWqY0mBLhgQPNzClPVjS6o4NyD0F8kuOvWNngFwpJBC08HhY2o8P1mgSgeJg==", + "license": "MIT", + "dependencies": { + "@expo/dom-webview": "^56.0.6", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^56.0.6", + "expo": "*", + "react": "*", + "react-native": "*" } }, "node_modules/expo/node_modules/@expo/metro-config": { - "version": "54.0.15", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.15.tgz", - "integrity": "sha512-SqIya4VZ9KHM1S9g+xR0A+QKw1Tfs7Gacx6bQNJ98vs4+O7I5+QP5mHZIB0QSZLUV8opiXebHYTiTu+0OAsIUw==", + "version": "56.0.16", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-56.0.16.tgz", + "integrity": "sha512-mxPZ23exC6kkEpPYQOteamaiWYER3uDk2IqKys7EJwtIKc3F1207Xtsdko/DNNG04DLwpN/WM2UlNl+Ak8uPRg==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.20.0", "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8", - "@expo/json-file": "~10.0.8", - "@expo/metro": "~54.2.0", - "@expo/spawn-async": "^1.7.2", + "@expo/config": "~56.0.11", + "@expo/env": "~2.3.1", + "@expo/json-file": "~10.2.0", + "@expo/metro": "~56.0.0", + "@expo/require-utils": "^56.1.4", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", "browserslist": "^4.25.0", "chalk": "^4.1.0", "debug": "^4.3.2", - "dotenv": "~16.4.5", - "dotenv-expand": "~11.0.6", "getenv": "^2.0.0", "glob": "^13.0.0", - "hermes-parser": "^0.29.1", + "hermes-parser": "^0.33.3", "jsc-safe-url": "^0.2.4", "lightningcss": "^1.30.1", - "picomatch": "^4.0.3", - "postcss": "~8.4.32", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", "resolve-from": "^5.0.0" }, "peerDependencies": { @@ -6424,49 +5696,59 @@ } } }, - "node_modules/expo/node_modules/@expo/vector-icons": { - "version": "15.1.1", - "resolved": "https://registry.npmjs.org/@expo/vector-icons/-/vector-icons-15.1.1.tgz", - "integrity": "sha512-Iu2VkcoI5vygbtYngm7jb4ifxElNVXQYdDrYkT7UCEIiKLeWnQY0wf2ZhHZ+Wro6Sc5TaumpKUOqDRpLi5rkvw==", - "license": "MIT", - "peerDependencies": { - "expo-font": ">=14.0.4", - "react": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/babel-preset-expo": { - "version": "54.0.10", - "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-54.0.10.tgz", - "integrity": "sha512-wTt7POavLFypLcPW/uC5v8y+mtQKDJiyGLzYCjqr9tx0Qc3vCXcDKk1iCFIj/++Iy5CWhhTflEa7VvVPNWeCfw==", + "version": "56.0.17", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-56.0.17.tgz", + "integrity": "sha512-yanAlbzaMMNqnju/uMnZsf3ltgssG71UubkfD+2iMaoXGNIj+TODEs9hF12jrTX2lzq/zH0HWSuRLZLNalf8fw==", "license": "MIT", "dependencies": { + "@babel/generator": "^7.20.5", "@babel/helper-module-imports": "^7.25.9", "@babel/plugin-proposal-decorators": "^7.12.9", "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-export-namespace-from": "^7.25.9", "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1", "@babel/plugin-transform-runtime": "^7.24.7", - "@babel/preset-react": "^7.22.15", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", "@babel/preset-typescript": "^7.23.0", - "@react-native/babel-preset": "0.81.5", + "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-native-web": "~0.21.0", - "babel-plugin-syntax-hermes-parser": "^0.29.1", + "babel-plugin-syntax-hermes-parser": "^0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", - "debug": "^4.3.4", - "resolve-from": "^5.0.0" + "debug": "^4.3.4" }, "peerDependencies": { "@babel/runtime": "^7.20.0", "expo": "*", + "expo-widgets": "^56.0.22", "react-refresh": ">=0.14.0 <1.0.0" }, "peerDependenciesMeta": { @@ -6475,18 +5757,12 @@ }, "expo": { "optional": true + }, + "expo-widgets": { + "optional": true } } }, - "node_modules/expo/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, "node_modules/expo/node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -6503,13 +5779,13 @@ } }, "node_modules/expo/node_modules/expo-asset": { - "version": "12.0.13", - "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.13.tgz", - "integrity": "sha512-x/p7WvQUnkn6K43b9eL6SPeq5Vnf1E8BDe9bDrWrvMqzyUvJnUFvl+ctg3034s/+UHe7Ne2pAmc0+yzbl8CrDQ==", + "version": "56.0.19", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-56.0.19.tgz", + "integrity": "sha512-huGY0bVfYUivNOir+iUEjjW9IbNHzLFNo8d6FGh22u1OsXcFR7Za3vpu5V1gMp0dc31wiqmsXkTazjm+Rro3vA==", "license": "MIT", "dependencies": { - "@expo/image-utils": "^0.8.8", - "expo-constants": "~18.0.13" + "@expo/image-utils": "^0.10.2", + "expo-constants": "~56.0.20" }, "peerDependencies": { "expo": "*", @@ -6517,24 +5793,10 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-constants": { - "version": "18.0.13", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz", - "integrity": "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ==", - "license": "MIT", - "dependencies": { - "@expo/config": "~12.0.13", - "@expo/env": "~2.0.8" - }, - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-file-system": { - "version": "19.0.22", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.22.tgz", - "integrity": "sha512-l9pgahSc7sJD0bP9vBNeXvZjy8QKDpVHVxWmei/ESQOrzmoj5BidziqLVsyZdxsi+PfdbTtttLTAmddH/JafYA==", + "version": "56.0.8", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.8.tgz", + "integrity": "sha512-NrH41/8snGIBSbYicwVLB4txPdgCATd7ZYhMAGS3YJZ9GbnduhlAoV4/YCbGayjrbpE9bJb/6wegPL/zmvRMnQ==", "license": "MIT", "peerDependencies": { "expo": "*", @@ -6542,9 +5804,9 @@ } }, "node_modules/expo/node_modules/expo-font": { - "version": "14.0.11", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", - "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.7.tgz", + "integrity": "sha512-hpU/vRwPzsby9lPGkA4blDqLIIXYzoWnCZHr6PxvcWbY/uPObAiyhh6q+e0WYsB65SthK+PLH95jEnVag7fwEg==", "license": "MIT", "dependencies": { "fontfaceobserver": "^2.1.0" @@ -6556,49 +5818,19 @@ } }, "node_modules/expo/node_modules/expo-keep-awake": { - "version": "15.0.8", - "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-15.0.8.tgz", - "integrity": "sha512-YK9M1VrnoH1vLJiQzChZgzDvVimVoriibiDIFLbQMpjYBnvyfUeHJcin/Gx1a+XgupNXy92EQJLgI/9ZuXajYQ==", + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", + "integrity": "sha512-CLMJXtEiMKknD3Rpm8CRwE6ZJUzu2yCEmRk1sgfHAJ1zIbuEWY3dpPDubtsnuzWm+2k6Sru+yaFbYsvPWmTiBA==", "license": "MIT", "peerDependencies": { "expo": "*", "react": "*" } }, - "node_modules/expo/node_modules/hermes-estree": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.29.1.tgz", - "integrity": "sha512-jl+x31n4/w+wEqm0I2r4CMimukLbLQEYpisys5oCre611CI5fc9TxhqkBBCJ1edDG4Kza0f7CgNz8xVMLZQOmQ==", - "license": "MIT" - }, - "node_modules/expo/node_modules/hermes-parser": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz", - "integrity": "sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA==", - "license": "MIT", - "dependencies": { - "hermes-estree": "0.29.1" - } - }, - "node_modules/expo/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/expo/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "license": "MIT", "engines": { "node": ">=12" @@ -6607,48 +5839,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/expo/node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/expo/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/exponential-backoff": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", @@ -6666,6 +5856,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { @@ -6675,6 +5866,18 @@ "dev": true, "license": "MIT" }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -6684,6 +5887,12 @@ "bser": "2.1.1" } }, + "node_modules/fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6808,15 +6017,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/freeport-async": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz", - "integrity": "sha512-K7od3Uw45AJg00XUmy15+Hae2hOcgKcmN3/EF6Y7i01O0gaqiRx8sUSpsb9+BRNL8RPBrhzPsVfy8q9ADlJuWQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -6826,16 +6026,11 @@ "node": ">= 0.6" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -6939,15 +6134,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -7041,10 +6227,10 @@ "node": "18 || 20 || >=22" } }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -7209,19 +6395,25 @@ "node": ">= 0.4" } }, + "node_modules/hermes-compiler": { + "version": "250829098.0.10", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.10.tgz", + "integrity": "sha512-TcRlZ0/TlyfJqquRFAWoyElVNnkdYRi/sEp4/Qy8/GYxjg8j2cS9D4MjuaQ+qimkmLN7AmO+44IznRf06mAr0w==", + "license": "MIT" + }, "node_modules/hermes-estree": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz", - "integrity": "sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ==", + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.33.3.tgz", + "integrity": "sha512-6kzYZHCk8Fy1Uc+t3HGYyJn3OL4aeqKLTyina4UFtWl8I0kSL7OmKThaiX+Uh2f8nGw3mo4Ifxg0M5Zk3/Oeqg==", "license": "MIT" }, "node_modules/hermes-parser": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.32.0.tgz", - "integrity": "sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==", + "version": "0.33.3", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.33.3.tgz", + "integrity": "sha512-Yg3HgaG4CqgyowtYjX/FsnPAuZdHOqSMtnbpylbptsQ9nwwSKsy6uRWcGO5RK0EqiX12q8HvDWKgeAVajRO5DA==", "license": "MIT", "dependencies": { - "hermes-estree": "0.32.0" + "hermes-estree": "0.33.3" } }, "node_modules/hosted-git-info": { @@ -7284,26 +6476,6 @@ "node": ">= 14" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7359,34 +6531,18 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC" - }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -7869,40 +7025,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -7921,23 +7043,6 @@ "node": ">= 0.4" } }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -7947,88 +7052,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/jest-util": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", @@ -8121,9 +7144,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -8171,6 +7204,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -8251,9 +7290,9 @@ } }, "node_modules/libphonenumber-js": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.1.tgz", - "integrity": "sha512-GEw0GLL7YUUA6nv21IsCvVjtI5Ejn84sjbdfQ9KxdbqEVOk1PZh7xejn01EEiniKw+dBeCfim+8MGeuvVuE2BA==", + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.6.tgz", + "integrity": "sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==", "license": "MIT" }, "node_modules/lighthouse-logger": { @@ -8542,12 +7581,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8737,45 +7770,44 @@ "license": "MIT" }, "node_modules/metro": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz", - "integrity": "sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.4.tgz", + "integrity": "sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", + "@babel/code-frame": "^7.29.0", "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/parser": "^7.25.3", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.3", - "@babel/types": "^7.25.2", - "accepts": "^1.3.7", - "chalk": "^4.0.0", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", "ci-info": "^2.0.0", "connect": "^3.6.5", "debug": "^4.4.0", "error-stack-parser": "^2.0.6", "flow-enums-runtime": "^0.0.6", "graceful-fs": "^4.2.4", - "hermes-parser": "0.32.0", + "hermes-parser": "0.35.0", "image-size": "^1.0.2", "invariant": "^2.2.4", "jest-worker": "^29.7.0", "jsc-safe-url": "^0.2.2", "lodash.throttle": "^4.1.1", - "metro-babel-transformer": "0.83.3", - "metro-cache": "0.83.3", - "metro-cache-key": "0.83.3", - "metro-config": "0.83.3", - "metro-core": "0.83.3", - "metro-file-map": "0.83.3", - "metro-resolver": "0.83.3", - "metro-runtime": "0.83.3", - "metro-source-map": "0.83.3", - "metro-symbolicate": "0.83.3", - "metro-transform-plugins": "0.83.3", - "metro-transform-worker": "0.83.3", - "mime-types": "^2.1.27", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-config": "0.84.4", + "metro-core": "0.84.4", + "metro-file-map": "0.84.4", + "metro-resolver": "0.84.4", + "metro-runtime": "0.84.4", + "metro-source-map": "0.84.4", + "metro-symbolicate": "0.84.4", + "metro-transform-plugins": "0.84.4", + "metro-transform-worker": "0.84.4", + "mime-types": "^3.0.1", "nullthrows": "^1.1.1", "serialize-error": "^2.1.0", "source-map": "^0.5.6", @@ -8787,88 +7819,104 @@ "metro": "src/cli.js" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-babel-transformer": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.83.3.tgz", - "integrity": "sha512-1vxlvj2yY24ES1O5RsSIvg4a4WeL7PFXgKOHvXTXiW0deLvQr28ExXj6LjwCCDZ4YZLhq6HddLpZnX4dEdSq5g==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.4.tgz", + "integrity": "sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", "flow-enums-runtime": "^0.0.6", - "hermes-parser": "0.32.0", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.4", "nullthrows": "^1.1.1" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/metro-babel-transformer/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro-babel-transformer/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" } }, "node_modules/metro-cache": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.83.3.tgz", - "integrity": "sha512-3jo65X515mQJvKqK3vWRblxDEcgY55Sk3w4xa6LlfEXgQ9g1WgMh9m4qVZVwgcHoLy0a2HENTPCCX4Pk6s8c8Q==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.4.tgz", + "integrity": "sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==", "license": "MIT", "dependencies": { "exponential-backoff": "^3.1.1", "flow-enums-runtime": "^0.0.6", "https-proxy-agent": "^7.0.5", - "metro-core": "0.83.3" + "metro-core": "0.84.4" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-cache-key": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.83.3.tgz", - "integrity": "sha512-59ZO049jKzSmvBmG/B5bZ6/dztP0ilp0o988nc6dpaDsU05Cl1c/lRf+yx8m9WW/JVgbmfO5MziBU559XjI5Zw==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.4.tgz", + "integrity": "sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-config": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz", - "integrity": "sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.4.tgz", + "integrity": "sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==", "license": "MIT", "dependencies": { "connect": "^3.6.5", "flow-enums-runtime": "^0.0.6", "jest-validate": "^29.7.0", - "metro": "0.83.3", - "metro-cache": "0.83.3", - "metro-core": "0.83.3", - "metro-runtime": "0.83.3", + "metro": "0.84.4", + "metro-cache": "0.84.4", + "metro-core": "0.84.4", + "metro-runtime": "0.84.4", "yaml": "^2.6.1" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-core": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz", - "integrity": "sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.4.tgz", + "integrity": "sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "lodash.throttle": "^4.1.1", - "metro-resolver": "0.83.3" + "metro-resolver": "0.84.4" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-file-map": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.83.3.tgz", - "integrity": "sha512-jg5AcyE0Q9Xbbu/4NAwwZkmQn7doJCKGW0SLeSJmzNB9Z24jBe0AL2PHNMy4eu0JiKtNWHz9IiONGZWq7hjVTA==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.4.tgz", + "integrity": "sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==", "license": "MIT", "dependencies": { "debug": "^4.4.0", @@ -8882,77 +7930,76 @@ "walker": "^1.0.7" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-minify-terser": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.83.3.tgz", - "integrity": "sha512-O2BmfWj6FSfzBLrNCXt/rr2VYZdX5i6444QJU0fFoc7Ljg+Q+iqebwE3K0eTvkI6TRjELsXk1cjU+fXwAR4OjQ==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.4.tgz", + "integrity": "sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "terser": "^5.15.0" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-resolver": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.83.3.tgz", - "integrity": "sha512-0js+zwI5flFxb1ktmR///bxHYg7OLpRpWZlBBruYG8OKYxeMP7SV0xQ/o/hUelrEMdK4LJzqVtHAhBm25LVfAQ==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.4.tgz", + "integrity": "sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-runtime": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz", - "integrity": "sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.4.tgz", + "integrity": "sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.25.0", "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-source-map": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz", - "integrity": "sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.4.tgz", + "integrity": "sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.3", - "@babel/traverse--for-generate-function-map": "npm:@babel/traverse@^7.25.3", - "@babel/types": "^7.25.2", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-symbolicate": "0.83.3", + "metro-symbolicate": "0.84.4", "nullthrows": "^1.1.1", - "ob1": "0.83.3", + "ob1": "0.84.4", "source-map": "^0.5.6", "vlq": "^1.0.0" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-symbolicate": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.83.3.tgz", - "integrity": "sha512-F/YChgKd6KbFK3eUR5HdUsfBqVsanf5lNTwFd4Ca7uuxnHgBC3kR/Hba/RGkenR3pZaGNp5Bu9ZqqP52Wyhomw==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.4.tgz", + "integrity": "sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6", "invariant": "^2.2.4", - "metro-source-map": "0.83.3", + "metro-source-map": "0.84.4", "nullthrows": "^1.1.1", "source-map": "^0.5.6", "vlq": "^1.0.0" @@ -8961,62 +8008,101 @@ "metro-symbolicate": "src/index.js" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-transform-plugins": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.83.3.tgz", - "integrity": "sha512-eRGoKJU6jmqOakBMH5kUB7VitEWiNrDzBHpYbkBXW7C5fUGeOd2CyqrosEzbMK5VMiZYyOcNFEphvxk3OXey2A==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.4.tgz", + "integrity": "sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.3", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", "flow-enums-runtime": "^0.0.6", "nullthrows": "^1.1.1" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/metro-transform-worker": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.83.3.tgz", - "integrity": "sha512-Ztekew9t/gOIMZX1tvJOgX7KlSLL5kWykl0Iwu2cL2vKMKVALRl1hysyhUw0vjpAvLFx+Kfq9VLjnHIkW32fPA==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.4.tgz", + "integrity": "sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==", "license": "MIT", "dependencies": { "@babel/core": "^7.25.2", - "@babel/generator": "^7.25.0", - "@babel/parser": "^7.25.3", - "@babel/types": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "flow-enums-runtime": "^0.0.6", - "metro": "0.83.3", - "metro-babel-transformer": "0.83.3", - "metro-cache": "0.83.3", - "metro-cache-key": "0.83.3", - "metro-minify-terser": "0.83.3", - "metro-source-map": "0.83.3", - "metro-transform-plugins": "0.83.3", + "metro": "0.84.4", + "metro-babel-transformer": "0.84.4", + "metro-cache": "0.84.4", + "metro-cache-key": "0.84.4", + "metro-minify-terser": "0.84.4", + "metro-source-map": "0.84.4", + "metro-transform-plugins": "0.84.4", "nullthrows": "^1.1.1" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, - "node_modules/metro/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "node_modules/metro/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">= 0.6" + } + }, + "node_modules/metro/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/metro/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/metro/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/metro/node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, "node_modules/micromatch": { @@ -9045,9 +8131,9 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -9065,6 +8151,15 @@ "node": ">= 0.6" } }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", @@ -9078,6 +8173,7 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -9104,18 +8200,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/mkdirp": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", @@ -9134,21 +8218,16 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } + "node_modules/multitars": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.0.tgz", + "integrity": "sha512-H/J4fMLedtudftaYMOg7ajzLYgT3/rwbWVJbqr/iUgB8DQztn38ys5HOqI1CzSxx8QhXXwOOnnBvd4v3jG5+Mg==", + "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "funding": [ { "type": "github", @@ -9195,12 +8274,6 @@ "node": ">= 0.6" } }, - "node_modules/nested-error-stacks": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz", - "integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==", - "license": "MIT" - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -9251,15 +8324,6 @@ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/npm-package-arg": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", @@ -9282,21 +8346,22 @@ "license": "MIT" }, "node_modules/ob1": { - "version": "0.83.3", - "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.83.3.tgz", - "integrity": "sha512-egUxXCDwoWG06NGCS5s5AdcpnumHKJlfd3HH06P3m9TEMwwScfcY35wpQxbm9oHof+dM/lVH9Rfyu1elTVelSA==", + "version": "0.84.4", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.4.tgz", + "integrity": "sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==", "license": "MIT", "dependencies": { "flow-enums-runtime": "^0.0.6" }, "engines": { - "node": ">=20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -9436,15 +8501,6 @@ "node": ">= 0.8" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/onetime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", @@ -9622,6 +8678,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -9649,15 +8706,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -9696,20 +8744,12 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9742,9 +8782,9 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -9762,19 +8802,10 @@ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "license": "MIT", - "engines": { - "node": ">= 6" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/plist": { @@ -9857,18 +8888,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-bytes": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", - "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -9958,19 +8977,12 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/qrcode-terminal": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz", - "integrity": "sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==", - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, "node_modules/queue": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", @@ -9980,6 +8992,12 @@ "inherits": "~2.0.3" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -9989,34 +9007,10 @@ "node": ">= 0.6" } }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10039,66 +9033,68 @@ "license": "MIT" }, "node_modules/react-native": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", - "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", - "license": "MIT", - "dependencies": { - "@jest/create-cache-key-function": "^29.7.0", - "@react-native/assets-registry": "0.81.5", - "@react-native/codegen": "0.81.5", - "@react-native/community-cli-plugin": "0.81.5", - "@react-native/gradle-plugin": "0.81.5", - "@react-native/js-polyfills": "0.81.5", - "@react-native/normalize-colors": "0.81.5", - "@react-native/virtualized-lists": "0.81.5", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.85.3.tgz", + "integrity": "sha512-HN/fGC+3nZVcDNcw7gfbM/DuqZAvI9Mz+/SxuhODaua4JY0BPzhfTzWXRyTR4mRgMHmShTPpH2PYMTxvZrsdZA==", + "license": "MIT", + "dependencies": { + "@react-native/assets-registry": "0.85.3", + "@react-native/codegen": "0.85.3", + "@react-native/community-cli-plugin": "0.85.3", + "@react-native/gradle-plugin": "0.85.3", + "@react-native/js-polyfills": "0.85.3", + "@react-native/normalize-colors": "0.85.3", + "@react-native/virtualized-lists": "0.85.3", "abort-controller": "^3.0.0", "anser": "^1.4.9", "ansi-regex": "^5.0.0", - "babel-jest": "^29.7.0", - "babel-plugin-syntax-hermes-parser": "0.29.1", + "babel-plugin-syntax-hermes-parser": "0.33.3", "base64-js": "^1.5.1", "commander": "^12.0.0", "flow-enums-runtime": "^0.0.6", - "glob": "^7.1.1", + "hermes-compiler": "250829098.0.10", "invariant": "^2.2.4", - "jest-environment-node": "^29.7.0", "memoize-one": "^5.0.0", - "metro-runtime": "^0.83.1", - "metro-source-map": "^0.83.1", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", "nullthrows": "^1.1.1", "pretty-format": "^29.7.0", "promise": "^8.3.0", "react-devtools-core": "^6.1.5", "react-refresh": "^0.14.0", "regenerator-runtime": "^0.13.2", - "scheduler": "0.26.0", + "scheduler": "0.27.0", "semver": "^7.1.3", "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", "whatwg-fetch": "^3.0.0", - "ws": "^6.2.3", + "ws": "^7.5.10", "yargs": "^17.6.2" }, "bin": { "react-native": "cli.js" }, "engines": { - "node": ">= 20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" }, "peerDependencies": { - "@types/react": "^19.1.0", - "react": "^19.1.0" + "@react-native/jest-preset": "0.85.3", + "@types/react": "^19.1.1", + "react": "^19.2.3" }, "peerDependenciesMeta": { + "@react-native/jest-preset": { + "optional": true + }, "@types/react": { "optional": true } } }, - "node_modules/react-native-is-edge-to-edge": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", - "integrity": "sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==", + "node_modules/react-native-safe-area-context": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", "license": "MIT", "peerDependencies": { "react": "*", @@ -10106,21 +9102,21 @@ } }, "node_modules/react-native/node_modules/@react-native/virtualized-lists": { - "version": "0.81.5", - "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.5.tgz", - "integrity": "sha512-UVXgV/db25OPIvwZySeToXD/9sKKhOdkcWmmf4Jh8iBZuyfML+/5CasaZ1E7Lqg6g3uqVQq75NqIwkYmORJMPw==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.85.3.tgz", + "integrity": "sha512-dsCjI//OIPEUJMyNHp4l7zNLVjCx7bcaRUceOCkU+IB17hkbtbGWvi7HjGFSzy7FJGmS/MOlcfpb72xXiy1Oig==", "license": "MIT", "dependencies": { "invariant": "^2.2.4", "nullthrows": "^1.1.1" }, "engines": { - "node": ">= 20.19.4" + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" }, "peerDependencies": { - "@types/react": "^19.1.0", + "@types/react": "^19.2.0", "react": "*", - "react-native": "*" + "react-native": "0.85.3" }, "peerDependenciesMeta": { "@types/react": { @@ -10137,36 +9133,6 @@ "node": ">=18" } }, - "node_modules/react-native/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/react-native/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "license": "MIT", - "dependencies": { - "async-limiter": "~1.0.0" - } - }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -10268,9 +9234,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", - "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", "license": "BSD-2-Clause", "dependencies": { "jsesc": "~3.1.0" @@ -10288,37 +9254,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requireg": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/requireg/-/requireg-0.2.2.tgz", - "integrity": "sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==", - "dependencies": { - "nested-error-stacks": "~2.0.1", - "rc": "~1.2.7", - "resolve": "~1.7.1" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/requireg/node_modules/resolve": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.7.1.tgz", - "integrity": "sha512-c7rwLofp8g1U+h1KNyHL/jicrKg1Ek4q+Lr33AL65uZTinUZHe30D5HlyN5V9NW0JX1D5dXQ4jqW5l7Sy/kGfw==", - "license": "MIT", - "dependencies": { - "path-parse": "^1.0.5" - } - }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -10368,15 +9303,6 @@ "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", "license": "MIT" }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/restore-cursor": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", @@ -10390,43 +9316,6 @@ "node": ">=4" } }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/safe-array-concat": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", @@ -10512,9 +9401,9 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, "node_modules/semver": { @@ -10708,9 +9597,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -10818,15 +9707,6 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/slugify": { "version": "1.6.9", "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", @@ -10873,12 +9753,6 @@ "node": ">=0.10.0" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "license": "BSD-3-Clause" - }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10886,27 +9760,6 @@ "dev": true, "license": "MIT" }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/stackframe": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", @@ -11110,37 +9963,6 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/sucrase/node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -11178,29 +10000,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar": { - "version": "7.5.15", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", - "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/terminal-link": { @@ -11243,62 +10052,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/throat": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", @@ -11350,6 +10103,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -11377,6 +10136,12 @@ "node": ">=0.6" } }, + "node_modules/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -11390,12 +10155,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "license": "Apache-2.0" - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -11426,19 +10185,16 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -11463,15 +10219,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/type-fest": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", @@ -11560,9 +10307,9 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -11592,15 +10339,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/undici": { - "version": "6.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.25.0.tgz", - "integrity": "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==", - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -11648,9 +10386,9 @@ } }, "node_modules/unimodules-app-loader": { - "version": "6.0.8", - "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz", - "integrity": "sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA==", + "version": "56.0.1", + "resolved": "https://registry.npmjs.org/unimodules-app-loader/-/unimodules-app-loader-56.0.1.tgz", + "integrity": "sha512-Z801jeBOQMUF/ExklxT1BqhEV/oF2/Bii7PFYAj/8Sauxl7oKvZbf70peRzzAU0mG7UQ3yU/UO/EpD1JyJ2WcA==", "license": "MIT" }, "node_modules/unpipe": { @@ -11747,13 +10485,16 @@ } }, "node_modules/uuid": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", - "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/validate-npm-package-name": { @@ -11798,34 +10539,17 @@ "defaults": "^1.0.3" } }, - "node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=8" - } - }, "node_modules/whatwg-fetch": { "version": "3.6.20", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", "license": "MIT" }, - "node_modules/whatwg-url-without-unicode": { - "version": "8.0.0-3", - "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", - "integrity": "sha512-HoKuzZrUlgpz35YO27XgD28uh/WJH4B0+3ttFqRo//lmq+9T/mIOJ6kqmINI9HpUpz1imRC/nR/lxKpJiv0uig==", - "license": "MIT", - "dependencies": { - "buffer": "^5.4.3", - "punycode": "^2.1.1", - "webidl-conversions": "^5.0.0" - }, - "engines": { - "node": ">=10" - } + "node_modules/whatwg-url-minimum": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", + "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==", + "license": "MIT" }, "node_modules/which": { "version": "2.0.2", @@ -11931,12 +10655,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/wonka": { - "version": "6.3.6", - "resolved": "https://registry.npmjs.org/wonka/-/wonka-6.3.6.tgz", - "integrity": "sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==", - "license": "MIT" - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -11964,36 +10682,17 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -12109,6 +10808,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12116,6 +10816,56 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zxing-wasm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/zxing-wasm/-/zxing-wasm-3.1.0.tgz", + "integrity": "sha512-5+3V1wPRx4gvbeLH2jB7n2cKrYJ1q4i3QgjnBUtrDPeqxJSi6BdzKJg4y6aF6bgW8zfntnYJyrkqFMevDhL2NA==", + "license": "MIT", + "dependencies": { + "@types/emscripten": "^1.41.5", + "type-fest": "^5.7.0" + }, + "peerDependencies": { + "@types/emscripten": ">=1.39.6" + } + }, + "node_modules/zxing-wasm/node_modules/type-fest": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.7.0.tgz", + "integrity": "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 5f3a441..369402f 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "clever-driver-app", - "version": "0.1.0", + "version": "1.0.0", "private": true, "main": "index.ts", "scripts": { "start": "expo start", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "lint": "expo lint", "typecheck": "tsc --noEmit", "test": "node scripts/run-tests.mjs", @@ -20,31 +20,45 @@ "check:source-layout": "tsx src/release/preflight/sourceLayoutPreflightCli.ts" }, "dependencies": { + "@maplibre/maplibre-react-native": "^11.3.4", "@react-native-async-storage/async-storage": "2.2.0", - "expo": "~54.0.33", - "expo-camera": "~17.0.10", - "expo-image-picker": "~17.0.11", - "expo-location": "~19.0.8", - "expo-secure-store": "~15.0.8", - "expo-speech": "~14.0.8", - "expo-status-bar": "~3.0.9", - "expo-task-manager": "~14.0.9", - "libphonenumber-js": "1.13.1", - "react": "19.1.0", - "react-native": "0.81.5" + "expo": "~56.0.15", + "expo-camera": "~56.0.8", + "expo-dev-client": "~56.0.22", + "expo-image-picker": "~56.0.20", + "expo-location": "~56.0.20", + "expo-notifications": "~56.0.20", + "expo-secure-store": "~56.0.4", + "expo-speech": "~56.0.3", + "expo-status-bar": "~56.0.4", + "expo-task-manager": "~56.0.21", + "libphonenumber-js": "1.13.6", + "react": "19.2.3", + "react-native": "0.85.3", + "react-native-safe-area-context": "~5.7.0" }, "devDependencies": { - "@types/node": "^20.19.41", - "@types/react": "~19.1.0", + "@types/node": "^20.19.43", + "@types/react": "~19.2.14", "eslint": "^9.0.0", - "eslint-config-expo": "~10.0.0", - "tsx": "^4.20.6", - "typescript": "~5.9.2" + "eslint-config-expo": "~56.0.4", + "tsx": "^4.22.4", + "typescript": "~6.0.3" }, "engines": { "node": ">=20.19.4" }, "overrides": { - "postcss": "8.5.10" + "postcss": "8.5.10", + "uuid": "11.1.1", + "ws": "8.21.0", + "shell-quote": "1.8.4" + }, + "expo": { + "doctor": { + "appConfigFieldsNotSyncedCheck": { + "enabled": false + } + } } } diff --git a/src/api/deliveryServer/driverApiClients.test.ts b/src/api/deliveryServer/driverApiClients.test.ts index 2c4d691..7ff4b4a 100644 --- a/src/api/deliveryServer/driverApiClients.test.ts +++ b/src/api/deliveryServer/driverApiClients.test.ts @@ -70,7 +70,17 @@ describe('driver API client token handoff', () => { }; }, persistedAccess: { + accountAccess: { + accessToken: 'account-access-token', + expiresAt: '2026-05-12T07:10:00.000Z', + refreshToken: 'account-refresh-token', + refreshTokenExpiresAt: '2026-06-12T07:00:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', + }, driverAccess: sampleInvitedRouteAccess.driverAccess, + driverProfile: { phoneE164: '+14165550123' }, routeAccess: sampleInvitedRouteAccess.routeAccess, }, }); @@ -85,4 +95,46 @@ describe('driver API client token handoff', () => { 'https://delivery.example.com/driver/assigned-route?routeContext=11111111-1111-4111-8111-111111111111', ); }); + + it('retries a driver API call once with refreshed access after an expired driver token', async () => { + const requests: { headers: Record; url: string }[] = []; + let refreshCount = 0; + const clients = createDriverApiClientsFromRouteAccess({ + baseUrl: 'https://delivery.example.com/', + fetchImpl: async (url, init) => { + requests.push({ headers: init?.headers ?? {}, url: String(url) }); + + if (requests.length === 1) { + return { + ok: false, + status: 401, + json: async () => ({ data: null, error: { code: 'UNAUTHORIZED' } }), + }; + } + + return { + ok: true, + json: async () => ({ data: { status: 'NO_ASSIGNED_ROUTE' }, error: null }), + }; + }, + refreshDriverAccess: async () => { + refreshCount += 1; + return { + ...sampleInvitedRouteAccess.driverAccess, + accessToken: 'fresh-driver-token', + }; + }, + routeAccess: sampleInvitedRouteAccess, + }); + + await clients.assignedRouteService.getAssignedRoute({ + routeContext: sampleInvitedRouteAccess.routeAccess.routeContext, + }); + + assert.equal(refreshCount, 1); + assert.deepEqual( + requests.map((request) => request.headers.Authorization), + ['Bearer fixture-driver-access-token', 'Bearer fresh-driver-token'], + ); + }); }); diff --git a/src/api/deliveryServer/driverApiClients.ts b/src/api/deliveryServer/driverApiClients.ts index 985aca5..a0ab7ab 100644 --- a/src/api/deliveryServer/driverApiClients.ts +++ b/src/api/deliveryServer/driverApiClients.ts @@ -13,13 +13,14 @@ import { type DriverEventService, type FetchLike as DriverEventFetchLike, } from '../../domain/events/driverEvents'; +import { getDriverApiRecoveryReason } from './driverApiError'; import type { PersistedDriverAccess } from '../../domain/driver/driverAccessTokenStore'; import { createProofMediaUploadApiClient, type FetchLike as ProofMediaUploadFetchLike, type ProofMediaUploadService, } from '../../domain/proof/proofMediaUpload'; -import type { RouteAccessLookupResult } from '../../domain/routeAccess/routeAccess'; +import type { DriverAccessToken, RouteAccessLookupResult } from '../../domain/routeAccess/routeAccess'; export type DriverApiClients = { assignedRouteService: AssignedRouteService; @@ -33,27 +34,33 @@ export type DriverApiClientsFetchLike = AssignedRouteFetchLike & DriverEventFetchLike & ProofMediaUploadFetchLike; +export type DriverAccessRefresh = () => Promise; + export function createDriverApiClientsFromRouteAccess(input: { baseUrl: string; fetchImpl?: DriverApiClientsFetchLike; + refreshDriverAccess?: DriverAccessRefresh; routeAccess: Extract; }): DriverApiClients { return createDriverApiClientsFromAccessToken({ accessToken: input.routeAccess.driverAccess.accessToken, baseUrl: input.baseUrl, fetchImpl: input.fetchImpl, + refreshDriverAccess: input.refreshDriverAccess, }); } export function createDriverApiClientsFromPersistedDriverAccess(input: { baseUrl: string; fetchImpl?: DriverApiClientsFetchLike; - persistedAccess: PersistedDriverAccess; + persistedAccess: PersistedDriverAccess & { driverAccess: DriverAccessToken }; + refreshDriverAccess?: DriverAccessRefresh; }): DriverApiClients { return createDriverApiClientsFromAccessToken({ accessToken: input.persistedAccess.driverAccess.accessToken, baseUrl: input.baseUrl, fetchImpl: input.fetchImpl, + refreshDriverAccess: input.refreshDriverAccess, }); } @@ -61,27 +68,79 @@ function createDriverApiClientsFromAccessToken(input: { accessToken: string; baseUrl: string; fetchImpl?: DriverApiClientsFetchLike; + refreshDriverAccess?: DriverAccessRefresh; }): DriverApiClients { - return { + const buildClients = (accessToken: string) => ({ assignedRouteService: createAssignedRouteApiClient({ - accessToken: input.accessToken, + accessToken, baseUrl: input.baseUrl, fetchImpl: input.fetchImpl, }), driverConsentService: createDriverConsentApiClient({ - accessToken: input.accessToken, + accessToken, baseUrl: input.baseUrl, fetchImpl: input.fetchImpl, }), driverEventService: createDriverEventsApiClient({ - accessToken: input.accessToken, + accessToken, baseUrl: input.baseUrl, fetchImpl: input.fetchImpl, }), proofMediaUploadService: createProofMediaUploadApiClient({ - accessToken: input.accessToken, + accessToken, baseUrl: input.baseUrl, fetchImpl: input.fetchImpl, }), + }); + + if (input.refreshDriverAccess === undefined) { + return buildClients(input.accessToken); + } + + return withDriverAccessRefresh({ + buildClients, + initialAccessToken: input.accessToken, + refreshDriverAccess: input.refreshDriverAccess, + }); +} + +function withDriverAccessRefresh(input: { + buildClients(accessToken: string): DriverApiClients; + initialAccessToken: string; + refreshDriverAccess: DriverAccessRefresh; +}): DriverApiClients { + let clients = input.buildClients(input.initialAccessToken); + + async function runWithRefresh(call: (clients: DriverApiClients) => Promise): Promise { + try { + return await call(clients); + } catch (error) { + if (getDriverApiRecoveryReason(error) !== 'driver_access_expired') { + throw error; + } + + const refreshedAccess = await input.refreshDriverAccess(); + if (refreshedAccess === null) { + throw error; + } + + clients = input.buildClients(refreshedAccess.accessToken); + return call(clients); + } + } + + return { + assignedRouteService: { + getAssignedRoute: (request) => runWithRefresh((client) => client.assignedRouteService.getAssignedRoute(request)), + }, + driverConsentService: { + recordDriverConsents: (request) => runWithRefresh((client) => client.driverConsentService.recordDriverConsents(request)), + }, + driverEventService: { + recordDriverEvent: (request) => runWithRefresh((client) => client.driverEventService.recordDriverEvent(request)), + }, + proofMediaUploadService: { + uploadProofMedia: (request) => runWithRefresh((client) => client.proofMediaUploadService.uploadProofMedia(request)), + }, }; } diff --git a/src/app/AppRoot.tsx b/src/app/AppRoot.tsx index 82ea72a..287259c 100644 --- a/src/app/AppRoot.tsx +++ b/src/app/AppRoot.tsx @@ -1,28 +1,51 @@ import { StatusBar } from 'expo-status-bar'; import * as Speech from 'expo-speech'; -import { useEffect, useMemo, useState } from 'react'; +import { CameraView, useCameraPermissions } from 'expo-camera'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, + Alert, + AppState, + BackHandler, + Image, + PanResponder, + InteractionManager, KeyboardAvoidingView, Linking, Platform, Pressable, - SafeAreaView, ScrollView, StyleSheet, Text, TextInput, View, } from 'react-native'; +import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'; import { createMockAssignedRouteService, + formatAssignedRouteItemLine, + formatAssignedRouteDistance, + formatAssignedRouteDuration, + formatAssignedRoutePaymentStatus, loadAssignedRouteAfterConsent, + resolveRouteMapPreviewState, sampleAssignedRoute, type AssignedRoute, type AssignedRouteService, type AssignedRouteStop, } from '../domain/route/assignedRoute'; +import { + classifyAssignedRouteSession, + filterVisibleAssignedRouteSessions, + getInitialAssignedRouteTab, + type RouteSessionStatus, +} from '../domain/route/routeSessionClassification'; +import { + getCurrentRouteStop, + getStopDetailsProgressState, + ROUTE_COMPANY_STEP_INDEX, +} from '../domain/route/routeStepProgress'; import { recordContinuousLocationUpdateBatch, startContinuousLocationUpdatesAfterDeliveryStart, @@ -48,8 +71,18 @@ import { type ProofMediaUploadService, } from '../domain/proof/proofMediaUpload'; import { createDriverRuntimeServices, readDriverRuntimeConfig } from './config/driverRuntimeConfig'; -import { createMockDriverConsentService, submitDriverConsent, type DriverConsentService, type DriverConsentSubmissionResult } from '../domain/consent/driverConsent'; +import { CONSENT_COPY_VERSIONS, createMockDriverConsentService, submitDriverConsent, type DriverConsentService, type DriverConsentSubmissionResult } from '../domain/consent/driverConsent'; import { getMvpRouteTabs } from '../domain/driverFlow/driverFlow'; +import { resetDriverSession } from '../domain/driver/driverSessionReset'; +import type { PersistedActiveRouteSession } from '../domain/driver/driverAccessTokenStore'; +import type { DriverAccountAccessToken } from '../domain/driverAuth/driverAuth'; +import { + getDriverMainTabs, + getDriverPlaceholderCopy, + getVisibleBottomTab, + shouldShowDriverBottomTabs, + type DriverMainTabId, +} from './driverMainTabs'; import { DEFAULT_DRIVER_PHONE_COUNTRY, findDriverPhoneCountry, @@ -62,31 +95,66 @@ import { createMockRouteAccessService, sampleInvitedRouteAccess, submitRouteAccess, + type DriverAccessToken, type RouteAccessCompanyGuidance, type RouteAccessLookupResult, type RouteAccessRouteChoice, type RouteAccessSubmissionResult, } from '../domain/routeAccess/routeAccess'; import { recordStopProofEventAfterDeliveryStart, type StopProofEventResult } from '../domain/stop/stopProofEvents'; +import { openRouteNavigation, openStopNavigation } from '../domain/stop/stopNavigation'; import { COUNTRY_SELECTOR_OVERLAY_BEHAVIOR, getCountrySelectorRowText, getSelectedCountryCardText, } from '../ui/components/countrySelectorBehavior'; +import { + getConsentCheckboxVisualState, +} from '../ui/components/authFormUxBehavior'; import { TransientToast } from '../ui/components/TransientToast'; import { scheduleTransientToastDismiss } from '../ui/components/transientToastBehavior'; +import { + buildAuthFailureMessage, + buildAuthSuccessMessage, + getRuntimeHostLabel, +} from './authDiagnostics'; +import { + getVerifiedDriverNoAssignedRouteMessage, + type VerifiedDriverNoAssignedRouteReason, +} from './verifiedDriverNoAssignedRoutes'; +import { NativeRouteMapPreview } from './NativeRouteMapPreview'; +import { getBottomChromeOffset, getBottomChromePadding, getBottomTabPadding, getScrollContentBottomPadding } from './appLayoutMetrics'; +import { readDriverMapStyleUrl } from './routeMapGeoJson'; +import { + getStopArrivalNotificationCandidate, + type StopArrivalNotificationData, +} from '../domain/notifications/stopArrivalNotifications'; +import { createExpoStopArrivalNotificationService } from '../platform/expo/notifications/expoStopArrivalNotificationService'; +import { requestRouteStartSessionConfirmation } from './routeStartConfirmation'; +import { + buildRoutePreviewSequence, + buildRoutePreviewRegionItems, + ROUTE_PREVIEW_COPY, + ROUTE_PREVIEW_LABELS, +} from './routePreviewBehavior'; type AppScreen = | 'arrivalCheck' | 'completedDeliveries' | 'liveTracking' - | 'login' - | 'routeDetail' - | 'routes' + | 'liveMapPreview' + | 'loginPhone' + | 'loginDetail' + | 'mainTabs' + | 'proofCamera' + | 'routePreview' + | 'routeSession' | 'stopCompleted' | 'stopDetails'; type RouteTabId = ReturnType[number]['id']; -type RouteStatus = 'active' | 'completed' | 'upcoming'; +type RouteStatus = RouteSessionStatus; +type StopDetailsBackTarget = 'liveTracking' | 'routeSession'; +type MapPreviewBackTarget = 'liveTracking' | 'routePreview' | 'routeSession'; type StopProofDraft = { additionalNotes: string; @@ -98,24 +166,59 @@ type RouteSession = RouteAccessRouteChoice & { route: AssignedRoute; }; -const DEFAULT_DRIVER_NAME = ''; -const COMPANY_STEP_INDEX = 0; +type RouteLoadOptions = { + allowVerifiedDriverNoRoute?: boolean; + activeRouteSession?: PersistedActiveRouteSession | null; + navigateOnSuccess?: boolean; + resetProgress?: boolean; + successMessagePrefix?: string; +}; + +const COMPANY_STEP_INDEX = ROUTE_COMPANY_STEP_INDEX; +const DRIVER_APP_VERSION = '1.0.0'; +const SWIPE_BACK_DISTANCE = 90; +const SWIPE_BACK_EDGE_WIDTH = 36; +const SWIPE_BACK_MAX_VERTICAL_DELTA = 90; +const SWIPE_BACK_DIRECTIONALITY_RATIO = 1.45; + +function runAfterUiInteractions(callback: () => void): void { + InteractionManager.runAfterInteractions(callback); +} export default function App() { - const [screen, setScreen] = useState('login'); + return ( + + + + ); +} + +function DriverApp() { + const { bottom: bottomInset } = useSafeAreaInsets(); + const bottomTabPadding = getBottomTabPadding(); + const [screen, setScreen] = useState('loginPhone'); const [selectedPhoneCountryIso2, setSelectedPhoneCountryIso2] = useState(DEFAULT_DRIVER_PHONE_COUNTRY.iso2); const [selectedDriverLocale, setSelectedDriverLocale] = useState(DEFAULT_DRIVER_PHONE_COUNTRY.defaultLocale); const [nationalPhoneInput, setNationalPhoneInput] = useState(''); const [countrySearchQuery, setCountrySearchQuery] = useState(''); const [isCountrySelectorOpen, setIsCountrySelectorOpen] = useState(false); - const [verificationCode, setVerificationCode] = useState(''); - const [driverName, setDriverName] = useState(DEFAULT_DRIVER_NAME); + const [inviteCode, setInviteCode] = useState(''); + const [pin, setPin] = useState(''); + const [pinConfirmation, setPinConfirmation] = useState(''); + const [isRegistration, setIsRegistration] = useState(false); + const [verifiedDriverPhoneE164, setVerifiedDriverPhoneE164] = useState(null); const [acceptedPrivacy, setAcceptedPrivacy] = useState(false); const [acceptedLocation, setAcceptedLocation] = useState(false); + const [selectedMainTab, setSelectedMainTab] = useState('home'); const [selectedTab, setSelectedTab] = useState('upcoming'); const [selectedRouteId, setSelectedRouteId] = useState(null); const [navigationStepIndex, setNavigationStepIndex] = useState(COMPANY_STEP_INDEX); + const [selectedStopDetailsId, setSelectedStopDetailsId] = useState(null); + const [stopDetailsBackTarget, setStopDetailsBackTarget] = useState('liveTracking'); + const [mapPreviewBackTarget, setMapPreviewBackTarget] = useState('routeSession'); const [routeSessions, setRouteSessions] = useState([]); + const [routeReviewNote, setRouteReviewNote] = useState(''); + const [pendingStopArrivalNotification, setPendingStopArrivalNotification] = useState(null); const [submission, setSubmission] = useState(null); const [, setConsentSubmission] = useState(null); @@ -135,14 +238,25 @@ export default function App() { const [message, setMessage] = useState(null); const [isLoggingIn, setIsLoggingIn] = useState(false); + const [isRefreshingRoutes, setIsRefreshingRoutes] = useState(false); const [isStartingRoute, setIsStartingRoute] = useState(false); const [isCapturingPhoto, setIsCapturingPhoto] = useState(false); + const [isPhotoActionSheetVisible, setIsPhotoActionSheetVisible] = useState(false); const [isCompletingStop, setIsCompletingStop] = useState(false); const [isFinishingRoute, setIsFinishingRoute] = useState(false); + const selectedRouteIdRef = useRef(null); + const notifiedStopArrivalIdsRef = useRef>(new Set()); + const hasCheckedInitialStopArrivalNotificationRef = useRef(false); + const hasAttemptedDriverRestoreRef = useRef(false); + + useEffect(() => { + selectedRouteIdRef.current = selectedRouteId; + }, [selectedRouteId]); const driverAccessTokenStore = useMemo(() => createExpoSecureDriverAccessTokenStore(), []); const foregroundLocationPermissionService = useMemo(() => createExpoForegroundLocationPermissionService(), []); const continuousLocationStreamService = useMemo(() => createExpoContinuousLocationStreamService(), []); + const stopArrivalNotificationService = useMemo(() => createExpoStopArrivalNotificationService(), []); const proofPhotoCaptureService = useMemo(() => createExpoProofPhotoCaptureService(), []); const offlineSubmissionQueueStorage = useMemo(() => createExpoOfflineSubmissionQueueStorage(), []); const mockDriverEventService = useMemo(() => createMockDriverEventService(), []); @@ -150,6 +264,7 @@ export default function App() { const mockAssignedRouteService = useMemo(() => createMockAssignedRouteService({ status: 'ASSIGNED_ROUTE', route: sampleAssignedRoute }), []); const mockProofMediaUploadService = useMemo(() => createMockProofMediaUploadService({ mode: 'success' }), []); const routeTabs = useMemo(() => getMvpRouteTabs(), []); + const mainTabs = useMemo(() => getDriverMainTabs(), []); const selectedPhoneCountry = findDriverPhoneCountry(selectedPhoneCountryIso2) ?? DEFAULT_DRIVER_PHONE_COUNTRY; const visiblePhoneCountries = useMemo( () => searchDriverPhoneCountries(countrySearchQuery), @@ -167,24 +282,163 @@ export default function App() { }), [], ); + const driverMapStyleUrl = useMemo(() => readDriverMapStyleUrl(process.env.EXPO_PUBLIC_DRIVER_MAP_STYLE_URL), []); + + const runtimeServices = useMemo(() => createDriverRuntimeServices({ config: runtimeConfig }), [runtimeConfig]); + const routeAccessService = useMemo(() => ( + runtimeConfig.mode === 'live' + ? runtimeServices.routeAccessService + : createMockRouteAccessService(sampleInvitedRouteAccess) + ), [runtimeConfig.mode, runtimeServices.routeAccessService]); + const driverAuthService = runtimeServices.driverAuthService; + + const getActiveAccountAccess = useCallback(async (): Promise => { + const restoredAccess = await driverAccessTokenStore.loadActiveDriverAccess(); + if (restoredAccess.kind === 'active') { + return restoredAccess.accountAccess; + } + if (restoredAccess.kind !== 'refresh_required') { + return null; + } - const routeAccessService = useMemo(() => { - if (runtimeConfig.mode === 'live') { - return createDriverRuntimeServices({ config: runtimeConfig }).routeAccessService; + const refreshResult = await driverAuthService.refreshSession({ + refreshToken: restoredAccess.accountAccess.refreshToken, + }); + await driverAccessTokenStore.saveRefreshedAccountAccess(refreshResult.accountAccess); + return refreshResult.accountAccess; + }, [driverAccessTokenStore, driverAuthService]); + + const refreshRouteAccessLookupForSubmission = useCallback(async (routePlanId: string): Promise => { + if (runtimeConfig.mode !== 'live') { + return null; } - return createMockRouteAccessService(sampleInvitedRouteAccess); - }, [runtimeConfig]); + try { + const accountAccess = await getActiveAccountAccess(); + if (accountAccess === null) { + return null; + } + const lookupResult = await submitRouteAccess({ + accountAccessToken: accountAccess.accessToken, + }, routeAccessService); + if (lookupResult.kind !== 'company_guidance' && lookupResult.kind !== 'route_choices') { + if (lookupResult.kind === 'denied' && lookupResult.status === 'NOT_FOUND') { + await driverAccessTokenStore.clearCachedRouteAccess(); + setRouteSessions([]); + setSubmission(null); + } + return null; + } + + const refreshedChoice = getRouteChoicesFromSubmission(lookupResult).find( + (choice) => choice.routeAccess.routePlanId === routePlanId, + ); + if (refreshedChoice === undefined) { + await driverAccessTokenStore.clearCachedRouteAccess(); + setRouteSessions((current) => current.filter( + (session) => session.routeAccess.routePlanId !== routePlanId, + )); + return null; + } + + const refreshedSubmission = toCompanyGuidanceSubmission(refreshedChoice); + setSubmission((current) => ( + current?.kind === 'company_guidance' && current.routeAccess.routePlanId === routePlanId + ? refreshedSubmission + : current + )); + setRouteSessions((current) => current.map((session) => ( + session.routeAccess.routePlanId === routePlanId ? { ...session, ...refreshedChoice } : session + ))); + void driverAccessTokenStore.saveFromInvitedRouteAccess(toInvitedRouteAccess(refreshedSubmission)).catch((error) => { + const errorMessage = error instanceof Error && error.message.trim() !== '' ? error.message : 'unknown error'; + console.warn(`[driver-api] Refreshed route access could not be saved: ${errorMessage}`); + }); + console.info('[driver-api] Refreshed route access after expired token.'); + return refreshedSubmission.driverAccess; + } catch (error) { + const errorMessage = error instanceof Error && error.message.trim() !== '' ? error.message : 'unknown error'; + console.warn(`[driver-api] Route access refresh failed: ${errorMessage}`); + return null; + } + }, [driverAccessTokenStore, getActiveAccountAccess, routeAccessService, runtimeConfig.mode]); + + const refreshDriverAccessForSubmission = useCallback(async ( + currentSubmission: Extract, + ): Promise => { + return refreshRouteAccessLookupForSubmission(currentSubmission.routeAccess.routePlanId); + }, [refreshRouteAccessLookupForSubmission]); + + const buildDriverAccessRefresh = useCallback(( + currentSubmission: RouteAccessSubmissionResult | null, + ): (() => Promise) | undefined => ( + currentSubmission?.kind === 'company_guidance' + ? () => refreshDriverAccessForSubmission(currentSubmission) + : undefined + ), [refreshDriverAccessForSubmission]); const selectedRouteSession = routeSessions.find((session) => session.route.id === selectedRouteId) ?? routeSessions[0] ?? null; const selectedRoute = selectedRouteSession?.route ?? null; const routeStatus = getRouteStatus(deliveryStartResult, deliveryFinishResult); - const currentStop = selectedRoute === null ? null : selectedRoute.stops[navigationStepIndex - 1] ?? null; + const currentStop = selectedRoute === null ? null : getCurrentRouteStop({ navigationStepIndex, route: selectedRoute }); + const stopDetailsProgressState = selectedRoute === null + ? null + : getStopDetailsProgressState({ + navigationStepIndex, + route: selectedRoute, + selectedStopDetailsId, + }); + const stopDetailsStop = stopDetailsProgressState?.stop ?? null; const isCompanyStep = navigationStepIndex === COMPANY_STEP_INDEX; const allStopsCompleted = selectedRoute !== null && selectedRoute.stops.every((stop) => completedStopIds.includes(stop.deliveryStopId)); const currentCompany = selectedRouteSession?.companyGuidance ?? null; const recentlyCompletedStop = selectedRoute?.stops.find((stop) => stop.deliveryStopId === recentlyCompletedStopId) ?? null; + const handleStopArrivalNotificationPress = useCallback((data: StopArrivalNotificationData) => { + const routeSession = routeSessions.find((session) => session.route.id === data.routePlanId) ?? null; + if (routeSession === null) { + setPendingStopArrivalNotification(data); + setMessage('Arrival alert opened. Loading assigned route before opening proof.'); + return; + } + + const stopIndex = routeSession.route.stops.findIndex((candidate) => candidate.deliveryStopId === data.deliveryStopId); + setPendingStopArrivalNotification(null); + + if (stopIndex < 0) { + setMessage('Arrival alert opened, but the stop is no longer available on this route.'); + return; + } + + if (completedStopIds.includes(data.deliveryStopId)) { + setSelectedRouteId(routeSession.route.id); + setSubmission(toCompanyGuidanceSubmission(routeSession)); + setNavigationStepIndex(stopIndex + 1); + setSelectedMainTab('home'); + setScreen('routeSession'); + setMessage('This stop is already completed.'); + return; + } + + if (deliveryStartResult?.kind !== 'delivery_active') { + setSelectedRouteId(routeSession.route.id); + setSubmission(toCompanyGuidanceSubmission(routeSession)); + setNavigationStepIndex(stopIndex + 1); + setSelectedMainTab('home'); + setScreen('routeSession'); + setMessage('Arrival alert opened, but the route is not active yet. Start the session first.'); + return; + } + + setSelectedRouteId(routeSession.route.id); + setSubmission(toCompanyGuidanceSubmission(routeSession)); + setSelectedStopDetailsId(null); + setNavigationStepIndex(stopIndex + 1); + setSelectedMainTab('home'); + setScreen('arrivalCheck'); + setMessage('Arrival alert opened. Add proof photo and delivery notes.'); + }, [completedStopIds, deliveryStartResult?.kind, routeSessions]); + useEffect(() => { let isMounted = true; createPersistentOfflineSubmissionQueue({ storage: offlineSubmissionQueueStorage }) @@ -212,6 +466,44 @@ export default function App() { message, }), [message]); + useEffect(() => { + const removeStopArrivalListener = stopArrivalNotificationService.addStopArrivalResponseListener(handleStopArrivalNotificationPress); + if (!hasCheckedInitialStopArrivalNotificationRef.current) { + hasCheckedInitialStopArrivalNotificationRef.current = true; + void stopArrivalNotificationService.getLastStopArrivalResponse().then((data) => { + if (data !== null) { + handleStopArrivalNotificationPress(data); + } + }); + } + + return removeStopArrivalListener; + }, [stopArrivalNotificationService, handleStopArrivalNotificationPress]); + + useEffect(() => { + if (pendingStopArrivalNotification !== null && routeSessions.length > 0) { + const timeout = setTimeout(() => { + handleStopArrivalNotificationPress(pendingStopArrivalNotification); + }, 0); + + return () => clearTimeout(timeout); + } + + return undefined; + }, [handleStopArrivalNotificationPress, pendingStopArrivalNotification, routeSessions.length]); + + useEffect(() => { + if (deliveryStartResult?.kind !== 'delivery_active' || deliveryFinishResult?.flowState === 'delivery_finished') { + return; + } + + void stopArrivalNotificationService.registerForStopArrivalNotifications().then((result) => { + if (result.kind !== 'registered') { + setMessage(result.message); + } + }); + }, [deliveryFinishResult?.flowState, deliveryStartResult?.kind, stopArrivalNotificationService]); + useEffect(() => { if (deliveryStartResult?.kind !== 'delivery_active' || deliveryFinishResult?.flowState === 'delivery_finished') { registerContinuousLocationTaskHandler(null); @@ -228,6 +520,7 @@ export default function App() { await recordContinuousLocationUpdateBatch({ driverEventService: getDriverEventServiceForCurrentSubmission({ fallback: mockDriverEventService, + refreshDriverAccess: buildDriverAccessRefresh(submission), runtimeConfig, submission, }), @@ -236,10 +529,39 @@ export default function App() { routePlanId, }); setOfflineQueueCount(queue.listPending().length); + + const lastLocation = locations[locations.length - 1] ?? null; + const candidate = getStopArrivalNotificationCandidate({ + completedStopIds, + currentStepIndex: navigationStepIndex, + isActiveRoute: routeStatus === 'active', + lastLocation, + notifiedStopIds: [...notifiedStopArrivalIdsRef.current], + route: selectedRoute, + }); + + if (candidate !== null) { + notifiedStopArrivalIdsRef.current.add(candidate.stop.deliveryStopId); + await stopArrivalNotificationService.scheduleStopArrivalNotification(candidate); + } }); return () => registerContinuousLocationTaskHandler(null); - }, [deliveryFinishResult, deliveryStartResult, mockDriverEventService, offlineSubmissionQueue, runtimeConfig, selectedRoute?.id, submission]); + }, [ + buildDriverAccessRefresh, + completedStopIds, + deliveryFinishResult, + deliveryStartResult, + mockDriverEventService, + navigationStepIndex, + offlineSubmissionQueue, + routeStatus, + runtimeConfig, + selectedRoute, + selectedRoute?.id, + stopArrivalNotificationService, + submission, + ]); function handlePhoneInputChange(value: string) { setNationalPhoneInput(formatDriverNationalPhoneInput({ @@ -259,7 +581,7 @@ export default function App() { })); } - function handleSendVerificationCode() { + async function handlePhoneSubmit() { const phoneEntry = normalizeDriverPhoneEntry({ countryIso2: selectedPhoneCountry.iso2, nationalPhoneInput, @@ -270,12 +592,22 @@ export default function App() { return; } - setMessage(`Verification code request is ready for server integration for ${phoneEntry.phoneE164}.`); + setIsRegistration(false); + setInviteCode(''); + setPin(''); + setPinConfirmation(''); + setMessage(`Enter your 6-digit PIN. ${getRuntimeHostLabel(runtimeConfig)}.`); + setScreen('loginDetail'); } - async function handleLoginAndLoadRoutes() { - if (driverName.trim().length === 0) { - setMessage('Enter the driver name before continuing.'); + async function handleDetailSubmit() { + const phoneEntry = normalizeDriverPhoneEntry({ + countryIso2: selectedPhoneCountry.iso2, + nationalPhoneInput, + }); + + if (!phoneEntry.ok) { + setMessage(formatDriverPhoneEntryProblem(phoneEntry.reason)); return; } @@ -284,36 +616,128 @@ export default function App() { return; } - setIsLoggingIn(true); - setMessage(null); - resetRouteProgress(); + const safePin = pin.trim(); + if (!/^\d{6}$/u.test(safePin)) { + setMessage('Enter your 6-digit numeric PIN.'); + return; + } + + const safeInviteCode = inviteCode.trim().toUpperCase(); + if (isRegistration && !/^[0-9A-F]{6}$/u.test(safeInviteCode)) { + setMessage('Enter the 6-character invite code from dispatch.'); + return; + } + if (isRegistration && pinConfirmation.trim() !== safePin) { + setMessage('PIN confirmation does not match.'); + return; + } + setIsLoggingIn(true); + setMessage(`${isRegistration ? 'Creating account' : 'Signing in'}... ${getRuntimeHostLabel(runtimeConfig)}.`); try { - const phoneEntry = normalizeDriverPhoneEntry({ - countryIso2: selectedPhoneCountry.iso2, - nationalPhoneInput, + const authResult = isRegistration + ? await driverAuthService.register({ + phoneE164: phoneEntry.phoneE164, + inviteCode: safeInviteCode, + pin: safePin, + }) + : await driverAuthService.login({ + phoneE164: phoneEntry.phoneE164, + pin: safePin, + }); + await driverAccessTokenStore.saveAuthenticatedDriver({ + accountAccess: authResult.accountAccess, + phoneE164: phoneEntry.phoneE164, + }); + setVerifiedDriverPhoneE164(phoneEntry.phoneE164); + setPin(''); + setPinConfirmation(''); + setMessage(buildAuthSuccessMessage({ + runtimeConfig, + phase: isRegistration ? 'invite_verify' : 'pin_login', + })); + setIsLoggingIn(false); + await handleLoginAndLoadRoutes( + authResult.accountAccess, + phoneEntry.phoneE164, + { allowVerifiedDriverNoRoute: true }, + ); + } catch (error) { + const failure = buildAuthFailureMessage({ + runtimeConfig, + phase: isRegistration ? 'invite_verify' : 'pin_login', + error, }); + setMessage(failure.message); + } finally { + setIsLoggingIn(false); + } + } - if (!phoneEntry.ok) { - setMessage(formatDriverPhoneEntryProblem(phoneEntry.reason)); - return; - } + function openMainTab(tab: DriverMainTabId) { + setSelectedMainTab(tab); + setScreen('mainTabs'); + if (tab === 'routes') { + void handleRefreshRoutes(); + } + } + + function openHomeRoot() { + openMainTab('home'); + } + + function openRoutesRoot() { + openMainTab('routes'); + } + + const openVerifiedNoAssignedRoute = useCallback((reason: VerifiedDriverNoAssignedRouteReason) => { + resetRouteProgress(); + setSelectedTab('upcoming'); + setSubmission(null); + setSelectedMainTab('routes'); + setScreen('mainTabs'); + setMessage(getVerifiedDriverNoAssignedRouteMessage(reason)); + void driverAccessTokenStore.clearCachedRouteAccess().catch(() => undefined); + }, [driverAccessTokenStore]); + + const handleLoginAndLoadRoutes = useCallback(async ( + accountAccess: DriverAccountAccessToken, + phoneE164: string, + options: RouteLoadOptions = {}, + ) => { + const allowVerifiedDriverNoRoute = options.allowVerifiedDriverNoRoute ?? false; + const shouldResetProgress = options.resetProgress ?? true; + const shouldNavigateOnSuccess = options.navigateOnSuccess ?? true; + const successMessagePrefix = options.successMessagePrefix ?? 'assigned'; + setIsLoggingIn(true); + setMessage(null); + setVerifiedDriverPhoneE164(phoneE164); + if (shouldResetProgress) { + resetRouteProgress(); + } - const lookupResult = await submitRouteAccess({ phoneE164: phoneEntry.phoneE164 }, routeAccessService); + try { + const lookupResult = await submitRouteAccess({ + accountAccessToken: accountAccess.accessToken, + }, routeAccessService); setSubmission(lookupResult); if (lookupResult.kind !== 'company_guidance' && lookupResult.kind !== 'route_choices') { + if (lookupResult.kind === 'denied' && lookupResult.status === 'NOT_FOUND') { + await driverAccessTokenStore.clearCachedRouteAccess(); + } + if (allowVerifiedDriverNoRoute && lookupResult.kind === 'denied' && lookupResult.status === 'NOT_FOUND') { + openVerifiedNoAssignedRoute('route_lookup_not_found'); + return; + } + setMessage(formatRouteAccessProblem(lookupResult)); return; } const choices = getRouteChoicesFromSubmission(lookupResult); if (choices.length === 0) { - setRouteSessions([]); - setSelectedRouteId(null); - setSelectedTab('upcoming'); - setScreen('routes'); - setMessage('Phone number verified. No active route is assigned right now.'); + openVerifiedNoAssignedRoute('no_route_choices'); return; } @@ -323,12 +747,13 @@ export default function App() { const choiceSubmission = toCompanyGuidanceSubmission(choice); const consentResult = await submitDriverConsent( { - appContext: { appVersion: '0.1.0', driverName: driverName.trim() }, + appContext: { appVersion: DRIVER_APP_VERSION }, deviceContext: { platform: Platform.OS }, routeContext: choice.routeAccess.routeContext, }, getDriverConsentServiceForCurrentSubmission({ fallback: mockDriverConsentService, + refreshDriverAccess: buildDriverAccessRefresh(choiceSubmission), runtimeConfig, submission: choiceSubmission, }), @@ -339,6 +764,8 @@ export default function App() { setMessage(consentResult.message); continue; } + setAcceptedPrivacy(true); + setAcceptedLocation(true); const assignedRouteResult = await loadAssignedRouteAfterConsent( { @@ -347,6 +774,7 @@ export default function App() { }, getAssignedRouteServiceForCurrentSubmission({ fallback: mockAssignedRouteService, + refreshDriverAccess: buildDriverAccessRefresh(choiceSubmission), runtimeConfig, submission: choiceSubmission, }), @@ -362,26 +790,218 @@ export default function App() { } if (loadedSessions.length === 0) { - setRouteSessions([]); - setSubmission(null); - setMessage('No active assigned route could be loaded for this phone number.'); + openVerifiedNoAssignedRoute('assigned_route_load_empty'); return; } + const activeRouteSession = options.activeRouteSession ?? null; + const restoredActiveSession = activeRouteSession === null + ? null + : loadedSessions.find((session) => session.route.id === activeRouteSession.routePlanId) ?? null; + const currentSelectedRouteId = selectedRouteIdRef.current; + const selectedRouteWasRemoved = currentSelectedRouteId !== null && + !loadedSessions.some((session) => session.route.id === currentSelectedRouteId); + const activeRouteWasRemoved = activeRouteSession !== null && restoredActiveSession === null; + if (selectedRouteWasRemoved || activeRouteWasRemoved) { + resetRouteProgress(); + await driverAccessTokenStore.clearActiveRouteSession(); + } setRouteSessions(loadedSessions); - setSelectedRouteId(loadedSessions[0].route.id); - const firstSubmission = toCompanyGuidanceSubmission(loadedSessions[0]); + const nextSelectedRouteId = restoredActiveSession !== null + ? restoredActiveSession.route.id + : currentSelectedRouteId !== null && loadedSessions.some((session) => session.route.id === currentSelectedRouteId) + ? currentSelectedRouteId + : loadedSessions[0].route.id; + setSelectedRouteId(nextSelectedRouteId); + const selectedSession = loadedSessions.find((session) => session.route.id === nextSelectedRouteId) ?? loadedSessions[0]; + const firstSubmission = toCompanyGuidanceSubmission(selectedSession); setSubmission(firstSubmission); - await driverAccessTokenStore.saveFromInvitedRouteAccess(toInvitedRouteAccess(firstSubmission)); - setSelectedTab('upcoming'); - setScreen('routes'); - setMessage(`${loadedSessions.length} route${loadedSessions.length === 1 ? '' : 's'} loaded.`); + void driverAccessTokenStore.saveFromInvitedRouteAccess(toInvitedRouteAccess(firstSubmission)).catch(() => { + runAfterUiInteractions(() => { + setMessage('Route loaded, but session persistence failed. Sign in again if the app does not restore this route next launch.'); + }); + }); + if (restoredActiveSession !== null) { + const restoredStepIndex = clampRouteNavigationStepIndex(activeRouteSession?.navigationStepIndex ?? COMPANY_STEP_INDEX, restoredActiveSession.route); + setDeliveryStartResult(getRestoredActiveDeliveryStartResult()); + setDeliveryFinishResult(null); + setSelectedTab('active'); + setSelectedMainTab('home'); + setNavigationStepIndex(restoredStepIndex); + setScreen('routeSession'); + runAfterUiInteractions(() => { + setMessage('Active route session restored. Continue from the current pickup or stop step.'); + }); + return; + } + + setSelectedTab(getInitialAssignedRouteTab({ + now: new Date(), + route: selectedSession.route, + })); + setSelectedMainTab(shouldNavigateOnSuccess ? 'home' : 'routes'); + setScreen('mainTabs'); + const routeLoadSuccessMessage = `${loadedSessions.length} ${successMessagePrefix} route${loadedSessions.length === 1 ? '' : 's'} loaded. ${buildAuthSuccessMessage({ runtimeConfig, phase: 'route_access' })}`; + runAfterUiInteractions(() => { + setMessage(routeLoadSuccessMessage); + }); + } catch (error) { + const failure = buildAuthFailureMessage({ + runtimeConfig, + phase: 'route_access', + error, + }); + if (failure.kind === 'server_401') { + await driverAccessTokenStore.clear(); + resetRouteProgress(); + setVerifiedDriverPhoneE164(null); + setScreen('loginPhone'); + setMessage('Your login expired. Sign in again with your phone number and PIN.'); + } else { + setMessage(failure.message); + } } finally { setIsLoggingIn(false); } + }, [ + buildDriverAccessRefresh, + driverAccessTokenStore, + mockAssignedRouteService, + mockDriverConsentService, + openVerifiedNoAssignedRoute, + routeAccessService, + runtimeConfig, + ]); + + const handleRefreshRoutes = useCallback(async () => { + if (verifiedDriverPhoneE164 === null) { + setMessage('Saved driver phone is unavailable. Sign in again to refresh routes.'); + return; + } + if (isRefreshingRoutes || isLoggingIn) { + return; + } + + setIsRefreshingRoutes(true); + try { + const restoredAccess = await driverAccessTokenStore.loadActiveDriverAccess(); + const accountAccess = await getActiveAccountAccess(); + if (accountAccess === null) { + setMessage('Your saved login expired. Sign in with your phone number and PIN.'); + resetRouteProgress(); + setVerifiedDriverPhoneE164(null); + setScreen('loginPhone'); + return; + } + await handleLoginAndLoadRoutes( + accountAccess, + verifiedDriverPhoneE164, + { + activeRouteSession: restoredAccess.kind === 'active' || restoredAccess.kind === 'refresh_required' + ? restoredAccess.activeRouteSession ?? null + : null, + allowVerifiedDriverNoRoute: true, + navigateOnSuccess: false, + resetProgress: false, + successMessagePrefix: 'refreshed assigned', + }, + ); + } finally { + setIsRefreshingRoutes(false); + } + }, [ + driverAccessTokenStore, + getActiveAccountAccess, + handleLoginAndLoadRoutes, + isLoggingIn, + isRefreshingRoutes, + verifiedDriverPhoneE164, + ]); + + useEffect(() => { + if (hasAttemptedDriverRestoreRef.current) { + return undefined; + } + + hasAttemptedDriverRestoreRef.current = true; + let isMounted = true; + driverAccessTokenStore.loadActiveDriverAccess().then(async (result) => { + if (!isMounted) { + return; + } + if (result.kind === 'expired' && result.driverProfile !== undefined) { + setNationalPhoneInput(result.driverProfile.phoneE164); + setMessage('Your saved login expired. Enter your PIN to continue.'); + return; + } + if (result.kind === 'active' || result.kind === 'refresh_required') { + try { + const accountAccess = result.kind === 'refresh_required' + ? (await driverAuthService.refreshSession({ + refreshToken: result.accountAccess.refreshToken, + })).accountAccess + : result.accountAccess; + if (result.kind === 'refresh_required') { + await driverAccessTokenStore.saveRefreshedAccountAccess(accountAccess); + } + setNationalPhoneInput(result.driverProfile.phoneE164); + setVerifiedDriverPhoneE164(result.driverProfile.phoneE164); + await handleLoginAndLoadRoutes( + accountAccess, + result.driverProfile.phoneE164, + { + activeRouteSession: result.activeRouteSession ?? null, + allowVerifiedDriverNoRoute: true, + }, + ); + } catch { + await driverAccessTokenStore.clear(); + if (isMounted) { + setMessage('Your saved login could not be refreshed. Sign in with your phone number and PIN.'); + setScreen('loginPhone'); + } + } + } + }); + return () => { isMounted = false; }; + }, [driverAccessTokenStore, driverAuthService, handleLoginAndLoadRoutes]); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (state) => { + if ( + state === 'active' && + screen === 'mainTabs' && + verifiedDriverPhoneE164 !== null + ) { + void handleRefreshRoutes(); + } + }); + + return () => subscription.remove(); + }, [handleRefreshRoutes, screen, verifiedDriverPhoneE164]); + + function handleStartRoute(routeId?: string) { + const routeSession = getRouteSessionForAction(routeSessions, routeId ?? selectedRouteId); + if (routeSession === null) { + setMessage('No route is available to start.'); + return; + } + + requestRouteStartSessionConfirmation({ + alertApi: { + alert: (title, message, buttons, options) => Alert.alert(title, message, buttons, options), + }, + route: { + deliveryDate: routeSession.route.deliveryDate, + timezone: routeSession.route.timezone, + }, + onConfirm: () => { + void startRouteSessionAfterConfirmed(routeSession.route.id); + }, + }); } - async function handleStartRoute(routeId?: string) { + async function startRouteSessionAfterConfirmed(routeId?: string) { const routeSession = getRouteSessionForAction(routeSessions, routeId ?? selectedRouteId); if (routeSession === null) { setMessage('No route is available to start.'); @@ -410,6 +1030,7 @@ export default function App() { const queue = offlineSubmissionQueue ?? undefined; const eventService = getDriverEventServiceForCurrentSubmission({ fallback: mockDriverEventService, + refreshDriverAccess: buildDriverAccessRefresh(activeSubmission), runtimeConfig, submission: activeSubmission, }); @@ -429,16 +1050,21 @@ export default function App() { setContinuousLocationResult(continuousResult); setSelectedTab('active'); + setSelectedMainTab('home'); setNavigationStepIndex(COMPANY_STEP_INDEX); - setScreen('liveTracking'); - setMessage('Route started. Begin with the company pickup step, then continue in stop order.'); + await driverAccessTokenStore.saveActiveRouteSession({ + navigationStepIndex: COMPANY_STEP_INDEX, + routePlanId: routeSession.route.id, + }); + setScreen('routeSession'); + setMessage('Route session started. Continue the pickup and stop workflow in the session.'); } finally { setIsStartingRoute(false); refreshOfflineQueueCount(); } } - function handleOpenRouteDetail(routeId?: string) { + function handleOpenRoutePreview(routeId?: string) { const routeSession = getRouteSessionForAction(routeSessions, routeId ?? selectedRouteId); if (routeSession === null) { setMessage('No route is available to review.'); @@ -447,11 +1073,30 @@ export default function App() { setSelectedRouteId(routeSession.route.id); setSubmission(toCompanyGuidanceSubmission(routeSession)); - setScreen('routeDetail'); + setSelectedMainTab('home'); + setScreen('routePreview'); + } + + function handleOpenRouteSession(routeId?: string) { + const routeSession = getRouteSessionForAction(routeSessions, routeId ?? selectedRouteId); + if (routeSession === null) { + setMessage('No route session is available to continue.'); + return; + } + + setSelectedRouteId(routeSession.route.id); + setSubmission(toCompanyGuidanceSubmission(routeSession)); + setSelectedMainTab('home'); + setScreen('routeSession'); } - async function handleCallCurrentStop() { - const phone = currentStop?.phone ?? currentCompany?.operatorSupportContact; + function openMapPreviewFrom(backTarget: MapPreviewBackTarget) { + setMapPreviewBackTarget(backTarget); + setScreen('liveMapPreview'); + } + + async function handleCallStop(stop: AssignedRouteStop | null) { + const phone = stop?.phone ?? currentCompany?.operatorSupportContact; if (phone === null || phone === undefined || phone.trim().length === 0) { setMessage('No contact number is available for this stop.'); return; @@ -460,8 +1105,8 @@ export default function App() { await Linking.openURL(`tel:${phone}`); } - async function handleMessageCurrentStop() { - const phone = currentStop?.phone ?? currentCompany?.operatorSupportContact; + async function handleMessageStop(stop: AssignedRouteStop | null) { + const phone = stop?.phone ?? currentCompany?.operatorSupportContact; if (phone === null || phone === undefined || phone.trim().length === 0) { setMessage('No message contact is available for this stop.'); return; @@ -471,7 +1116,11 @@ export default function App() { } function handleAnnounceCurrentTip() { - const text = getNavigationTip({ company: currentCompany, isCompanyStep, stop: currentStop }); + handleAnnounceNavigationTip({ isCompanyStep, stop: currentStop }); + } + + function handleAnnounceNavigationTip(input: { isCompanyStep: boolean; stop: AssignedRouteStop | null }) { + const text = getNavigationTip({ company: currentCompany, isCompanyStep: input.isCompanyStep, stop: input.stop }); Speech.stop(); Speech.speak(text, { language: 'en-CA', rate: 0.94 }); setMessage(`Voice tip: ${text}`); @@ -484,7 +1133,11 @@ export default function App() { if (isCompanyStep) { setNavigationStepIndex(1); - setScreen('liveTracking'); + void driverAccessTokenStore.saveActiveRouteSession({ + navigationStepIndex: 1, + routePlanId: selectedRoute.id, + }); + setScreen('routeSession'); setMessage('Company pickup confirmed. Continue to the first stop.'); return; } @@ -499,12 +1152,58 @@ export default function App() { return; } + setSelectedStopDetailsId(currentStop.deliveryStopId); + setStopDetailsBackTarget('routeSession'); + setScreen('stopDetails'); + } + + function handleOpenStopFromRouteSession(stop: AssignedRouteStop) { + if (selectedRoute === null) { + setMessage('No route is available to review.'); + return; + } + + const selectedStop = selectedRoute.stops.find((candidate) => candidate.deliveryStopId === stop.deliveryStopId); + if (selectedStop === undefined) { + setMessage('This stop is no longer available on the selected route.'); + return; + } + + setSelectedStopDetailsId(selectedStop.deliveryStopId); + setStopDetailsBackTarget('routeSession'); setScreen('stopDetails'); } + async function handleOpenRouteNavigation(route: AssignedRoute | null) { + if (route === null) { + setMessage('No route is available to open in map.'); + return; + } + + const result = await openRouteNavigation({ + linking: Linking, + route, + }); + setMessage(result.message); + } + + async function handleOpenNavigationForStop(stop: AssignedRouteStop | null) { + if (stop === null || selectedRoute === null) { + setMessage('No stop is available to open in map.'); + return; + } + + const result = await openStopNavigation({ + linking: Linking, + platform: Platform.OS, + stop, + }); + setMessage(result.message); + } + function handleContinueAfterStopCompleted() { if (selectedRoute === null) { - setScreen('routes'); + openHomeRoot(); return; } @@ -513,7 +1212,53 @@ export default function App() { return; } - setScreen('liveTracking'); + setScreen('routeSession'); + } + + async function handleProofPhotoResult(input: { + captureResult: ProofPhotoCaptureResult; + route: AssignedRoute; + stop: AssignedRouteStop; + }) { + const { captureResult, route, stop } = input; + setProofPhotoResults((current) => ({ ...current, [stop.deliveryStopId]: captureResult })); + + const uploadRequest = { + deliveryStopId: stop.deliveryStopId, + fileName: getFileNameFromUri(captureResult.kind === 'captured' ? captureResult.uri : '', stop.deliveryStopId), + routePlanId: route.id, + }; + + const uploadResult = await uploadCapturedProofPhoto({ + captureResult, + uploadRequest, + uploadService: getProofMediaUploadServiceForCurrentSubmission({ + fallback: mockProofMediaUploadService, + refreshDriverAccess: buildDriverAccessRefresh(submission), + runtimeConfig, + submission, + }), + }); + setProofMediaResults((current) => ({ ...current, [stop.deliveryStopId]: uploadResult })); + + if (uploadResult.kind === 'upload_failed') { + console.warn(`[proof-media] ${uploadResult.message}`); + } + + if (shouldQueueFailedProofMediaUpload(uploadResult) && captureResult.kind === 'captured') { + offlineSubmissionQueue?.enqueueProofMediaUpload({ + deliveryStopId: stop.deliveryStopId, + fileName: getFileNameFromUri(captureResult.uri, stop.deliveryStopId), + routePlanId: route.id, + source: captureResult.source, + uri: captureResult.uri, + }); + } + + const photoMessage = formatPhotoResult(captureResult, uploadResult); + if (photoMessage !== null) { + setMessage(photoMessage); + } } async function handleCapturePhoto(source: ProofPhotoCaptureSource) { @@ -521,73 +1266,96 @@ export default function App() { return; } + const stop = currentStop; + const route = selectedRoute; setIsCapturingPhoto(true); setMessage(null); try { const captureResult = await captureProofPhoto({ captureService: proofPhotoCaptureService, source }); - setProofPhotoResults((current) => ({ ...current, [currentStop.deliveryStopId]: captureResult })); - - const uploadResult = await uploadCapturedProofPhoto({ - captureResult, - uploadRequest: { - deliveryStopId: currentStop.deliveryStopId, - fileName: getFileNameFromUri(captureResult.kind === 'captured' ? captureResult.uri : '', currentStop.deliveryStopId), - routePlanId: selectedRoute.id, - }, - uploadService: getProofMediaUploadServiceForCurrentSubmission({ - fallback: mockProofMediaUploadService, - runtimeConfig, - submission, - }), - }); - setProofMediaResults((current) => ({ ...current, [currentStop.deliveryStopId]: uploadResult })); - - if (shouldQueueFailedProofMediaUpload(uploadResult) && captureResult.kind === 'captured') { - offlineSubmissionQueue?.enqueueProofMediaUpload({ - deliveryStopId: currentStop.deliveryStopId, - fileName: getFileNameFromUri(captureResult.uri, currentStop.deliveryStopId), - routePlanId: selectedRoute.id, - source: captureResult.source, - uri: captureResult.uri, - }); - } - - setMessage(formatPhotoResult(captureResult, uploadResult)); + await handleProofPhotoResult({ captureResult, route, stop }); } finally { setIsCapturingPhoto(false); refreshOfflineQueueCount(); } } - async function handleCompleteCurrentStop() { - if (currentStop === null || selectedRoute === null || deliveryStartResult === null) { - return; - } - - const photoResult = proofPhotoResults[currentStop.deliveryStopId]; - if (photoResult?.kind !== 'captured') { - setMessage('Photo proof is required. Capture or select a proof photo first.'); + async function handleCapturedCameraPhoto(uri: string) { + if (currentStop === null || selectedRoute === null) { + setScreen('arrivalCheck'); return; } - setIsCompletingStop(true); + const stop = currentStop; + const route = selectedRoute; + setScreen('arrivalCheck'); + setIsCapturingPhoto(true); setMessage(null); try { - const draft = getProofDraft(proofDrafts[currentStop.deliveryStopId]); - const mediaResult = proofMediaResults[currentStop.deliveryStopId]; - const result = await recordStopProofEventAfterDeliveryStart({ - deliveryStart: deliveryStartResult, - driverEventService: getDriverEventServiceForCurrentSubmission({ - fallback: mockDriverEventService, + await handleProofPhotoResult({ + captureResult: { kind: 'captured', source: 'camera', uri }, + route, + stop, + }); + } finally { + setIsCapturingPhoto(false); + refreshOfflineQueueCount(); + } + } + + function handleAddDeliveryPhoto() { + setIsPhotoActionSheetVisible(true); + } + + function handleDismissPhotoActionSheet() { + setIsPhotoActionSheetVisible(false); + } + + function handleSelectPhotoSource(source: ProofPhotoCaptureSource) { + setIsPhotoActionSheetVisible(false); + if (source === 'camera') { + setScreen('proofCamera'); + return; + } + + void handleCapturePhoto(source); + } + + async function handleCompleteCurrentStop() { + if (currentStop === null || selectedRoute === null || deliveryStartResult === null) { + return; + } + + const photoResult = proofPhotoResults[currentStop.deliveryStopId]; + if (photoResult?.kind !== 'captured') { + setMessage('Add a delivery photo first.'); + return; + } + + const mediaResult = proofMediaResults[currentStop.deliveryStopId]; + if (mediaResult?.kind !== 'uploaded') { + setMessage(mediaResult?.kind === 'upload_failed' ? mediaResult.message : 'Photo is not uploaded yet. Add the photo again.'); + return; + } + + setIsCompletingStop(true); + setMessage(null); + + try { + const draft = getProofDraft(proofDrafts[currentStop.deliveryStopId]); + const result = await recordStopProofEventAfterDeliveryStart({ + deliveryStart: deliveryStartResult, + driverEventService: getDriverEventServiceForCurrentSubmission({ + fallback: mockDriverEventService, + refreshDriverAccess: buildDriverAccessRefresh(submission), runtimeConfig, submission, }), input: { action: 'delivered', deliveryStopId: currentStop.deliveryStopId, - media: mediaResult?.kind === 'uploaded' ? [mediaResult.media] : [], + media: [mediaResult.media], note: formatStopProofNote(draft), photoUris: [photoResult.uri], routePlanId: selectedRoute.id, @@ -615,7 +1383,12 @@ export default function App() { return; } - setNavigationStepIndex((index) => index + 1); + const nextNavigationStepIndex = navigationStepIndex + 1; + setNavigationStepIndex(nextNavigationStepIndex); + await driverAccessTokenStore.saveActiveRouteSession({ + navigationStepIndex: nextNavigationStepIndex, + routePlanId: selectedRoute.id, + }); setScreen('stopCompleted'); setMessage('Stop completed. Continue to the next stop when ready.'); } finally { @@ -635,6 +1408,7 @@ export default function App() { deliveryStart: deliveryStartResult, driverEventService: getDriverEventServiceForCurrentSubmission({ fallback: mockDriverEventService, + refreshDriverAccess: buildDriverAccessRefresh(submission), runtimeConfig, submission, }), @@ -645,8 +1419,10 @@ export default function App() { setDeliveryFinishResult(finishResult); if (finishResult.kind !== 'blocked') { setContinuousLocationResult({ kind: 'stopped', taskName: finishResult.stoppedTaskName }); + await driverAccessTokenStore.clearActiveRouteSession(); } setSelectedTab('completed'); + setSelectedMainTab('home'); setScreen('completedDeliveries'); setMessage(finishResult.message); } finally { @@ -685,6 +1461,9 @@ export default function App() { setDeliveryFinishResult(null); setRouteStartedEventResult(null); setContinuousLocationResult(null); + notifiedStopArrivalIdsRef.current.clear(); + hasCheckedInitialStopArrivalNotificationRef.current = false; + setPendingStopArrivalNotification(null); setStopProofResults({}); setProofDrafts({}); setProofPhotoResults({}); @@ -693,6 +1472,8 @@ export default function App() { setCompletedStopTimes({}); setRecentlyCompletedStopId(null); setNavigationStepIndex(COMPANY_STEP_INDEX); + setSelectedStopDetailsId(null); + setStopDetailsBackTarget('liveTracking'); setSelectedRouteId(null); } @@ -700,49 +1481,226 @@ export default function App() { setOfflineQueueCount(offlineSubmissionQueue?.listPending().length ?? 0); } + async function handleLogout() { + setMessage(null); + if (offlineSubmissionQueue !== null) { + const resetResult = await resetDriverSession({ + driverAccessTokenStore, + offlineQueue: offlineSubmissionQueue, + }); + setMessage(`Signed out. Cleared ${resetResult.clearedOfflineSubmissions} offline retry item${resetResult.clearedOfflineSubmissions === 1 ? '' : 's'}.`); + } else { + await driverAccessTokenStore.clear(); + setMessage('Signed out. Offline retry storage was not ready.'); + } + + resetRouteProgress(); + setSelectedMainTab('home'); + setSelectedTab('upcoming'); + setVerifiedDriverPhoneE164(null); + setInviteCode(''); + setPin(''); + setPinConfirmation(''); + setIsRegistration(false); + setAcceptedPrivacy(false); + setAcceptedLocation(false); + setRouteReviewNote(''); + setScreen('loginPhone'); + } + + function handleRequestAccountDeletionInfo() { + setMessage(getDriverPlaceholderCopy('accountDeletion')); + } + + function handleContinueActiveRoute() { + if (selectedRoute === null) { + openRoutesRoot(); + return; + } + + if (routeStatus === 'active') { + setSelectedMainTab('home'); + setScreen('routeSession'); + return; + } + + setSelectedMainTab('home'); + setScreen('routeSession'); + } + + const handleAppBack = useCallback((): boolean => { + switch (screen) { + case 'loginPhone': + case 'mainTabs': + return false; + case 'loginDetail': + setInviteCode(''); + setPin(''); + setPinConfirmation(''); + setIsRegistration(false); + setScreen('loginPhone'); + return true; + case 'routePreview': + case 'routeSession': + setSelectedMainTab('home'); + setScreen('mainTabs'); + return true; + case 'liveTracking': + setScreen('routeSession'); + return true; + case 'liveMapPreview': + setScreen(mapPreviewBackTarget); + return true; + case 'proofCamera': + setScreen('arrivalCheck'); + return true; + case 'stopDetails': + setSelectedStopDetailsId(null); + setScreen(stopDetailsBackTarget); + return true; + case 'arrivalCheck': + setScreen('stopDetails'); + return true; + case 'stopCompleted': + setScreen('routeSession'); + return true; + case 'completedDeliveries': + setSelectedMainTab('home'); + setScreen('mainTabs'); + return true; + } + }, [mapPreviewBackTarget, screen, stopDetailsBackTarget]); + + useEffect(() => { + if (Platform.OS !== 'android') { + return undefined; + } + + const subscription = BackHandler.addEventListener('hardwareBackPress', handleAppBack); + return () => subscription.remove(); + }, [handleAppBack]); + + const swipeBackResponder = useMemo(() => PanResponder.create({ + onMoveShouldSetPanResponder: (_event, gestureState) => { + if (screen === 'loginPhone' || screen === 'mainTabs' || screen === 'liveMapPreview' || gestureState.x0 > SWIPE_BACK_EDGE_WIDTH) { + return false; + } + + const horizontalDistance = Math.abs(gestureState.dx); + const verticalDistance = Math.abs(gestureState.dy); + return horizontalDistance > 35 && horizontalDistance > verticalDistance * SWIPE_BACK_DIRECTIONALITY_RATIO; + }, + onPanResponderRelease: (_event, gestureState) => { + const horizontalDistance = Math.abs(gestureState.dx); + const verticalDistance = Math.abs(gestureState.dy); + + if (horizontalDistance >= SWIPE_BACK_DISTANCE && verticalDistance <= SWIPE_BACK_MAX_VERTICAL_DELTA) { + handleAppBack(); + } + }, + }), [handleAppBack, screen]); + const isFullMapScreen = screen === 'liveMapPreview' && selectedRoute !== null; + const isProofCameraScreen = screen === 'proofCamera'; + const contentBottomPadding = shouldShowDriverBottomTabs(screen) ? getScrollContentBottomPadding(bottomInset) : 28; + const scrollContentContainerStyle = [styles.container, { paddingBottom: contentBottomPadding }]; + return ( - + - - - {screen === 'login' ? ( - + {isFullMapScreen ? ( + setScreen(mapPreviewBackTarget)} + route={selectedRoute} + /> + ) : isProofCameraScreen ? ( + setScreen('arrivalCheck')} + onCaptured={(uri) => { + void handleCapturedCameraPhoto(uri); + }} + onOpenGallery={() => { + setScreen('arrivalCheck'); + void handleCapturePhoto('library'); + }} + /> + ) : ( + + {screen === 'loginPhone' ? ( + setIsCountrySelectorOpen((current) => !current)} - onDriverNameChange={setDriverName} onPhoneChange={handlePhoneInputChange} - onSendCode={handleSendVerificationCode} - onSubmit={handleLoginAndLoadRoutes} - onVerificationCodeChange={setVerificationCode} + onSendCode={handlePhoneSubmit} phoneE164Preview={phoneE164Preview} selectedDriverLocale={selectedDriverLocale} selectedPhoneCountry={selectedPhoneCountry} - verificationCode={verificationCode} /> ) : null} - {screen === 'routes' ? ( - + ) : null} + + {screen === 'mainTabs' && selectedMainTab === 'home' ? ( + selectedRoute !== null ? handleOpenRoutePreview(selectedRoute.id) : undefined} + onReviewNoteChange={setRouteReviewNote} + onStartRoute={() => selectedRoute !== null ? handleStartRoute(selectedRoute.id) : undefined} + route={selectedRoute} + routeReviewNote={routeReviewNote} + routeStatus={routeStatus} + /> + ) : null} + + {screen === 'mainTabs' && selectedMainTab === 'routes' ? ( + setScreen('completedDeliveries')} - onOpenRouteDetail={handleOpenRouteDetail} + onOpenRoutePreview={handleOpenRoutePreview} + onContinueRoute={handleOpenRouteSession} + onRefreshRoutes={handleRefreshRoutes} onSelectRoute={setSelectedRouteId} onSelectTab={setSelectedTab} onStartRoute={handleStartRoute} @@ -754,32 +1712,56 @@ export default function App() { /> ) : null} - {screen === 'routeDetail' && selectedRoute !== null ? ( - + ) : null} + + {screen === 'mainTabs' && selectedMainTab === 'profile' ? ( + + ) : null} + + {screen === 'routeSession' && selectedRoute !== null ? ( + setScreen('routes')} + isCompanyStep={isCompanyStep} + mapStyleUrl={driverMapStyleUrl} + onArrived={handleArrivedAtStep} + onBack={openHomeRoot} onFinishRoute={handleManualFinishRoute} + onOpenMapPreview={() => openMapPreviewFrom('routeSession')} + onOpenNavigation={() => handleOpenRouteNavigation(selectedRoute)} + onOpenStop={handleOpenStopFromRouteSession} onStartRoute={() => handleStartRoute(selectedRoute.id)} + onViewCurrentStop={handleViewCurrentStop} route={selectedRoute} routeStartedEventResult={routeStartedEventResult} routeStatus={routeStatus} + stop={currentStop} /> ) : null} {screen === 'liveTracking' && selectedRoute !== null ? ( setScreen('routeDetail')} + onBack={() => setScreen('routeSession')} + onOpenMapPreview={() => openMapPreviewFrom('liveTracking')} + onOpenNavigation={() => handleOpenRouteNavigation(selectedRoute)} onViewStop={handleViewCurrentStop} route={selectedRoute} routeStatus={routeStatus} @@ -787,15 +1769,24 @@ export default function App() { /> ) : null} - {screen === 'stopDetails' && currentStop !== null ? ( + {screen === 'routePreview' && selectedRoute !== null ? ( + openMapPreviewFrom('routePreview')} + route={selectedRoute} + /> + ) : null} + + {screen === 'stopDetails' && stopDetailsStop !== null ? ( setScreen('liveTracking')} - onCall={handleCallCurrentStop} - onMessage={handleMessageCurrentStop} - stop={currentStop} + onBack={() => { + handleAppBack(); + }} + onCall={() => handleCallStop(stopDetailsStop)} + onMessage={() => handleMessageStop(stopDetailsStop)} + onOpenNavigation={() => handleOpenNavigationForStop(stopDetailsStop)} + stop={stopDetailsStop} /> ) : null} @@ -804,13 +1795,11 @@ export default function App() { draft={getProofDraft(proofDrafts[currentStop.deliveryStopId])} isCapturingPhoto={isCapturingPhoto} isCompletingStop={isCompletingStop || isFinishingRoute} - mediaResult={proofMediaResults[currentStop.deliveryStopId]} onAnnounceTip={handleAnnounceCurrentTip} onBack={() => setScreen('stopDetails')} - onCapturePhoto={handleCapturePhoto} + onAddPhoto={handleAddDeliveryPhoto} onCompleteStop={handleCompleteCurrentStop} onDraftChange={updateCurrentStopDraft} - photoResult={proofPhotoResults[currentStop.deliveryStopId]} proofResult={stopProofResults[currentStop.deliveryStopId]} stop={currentStop} /> @@ -821,7 +1810,7 @@ export default function App() { completedStop={recentlyCompletedStop} completedStopIds={completedStopIds} completedStopTimes={completedStopTimes} - onBackToRoute={() => setScreen('routeDetail')} + onBackToRoute={() => setScreen('routeSession')} onContinue={handleContinueAfterStopCompleted} route={selectedRoute} /> @@ -831,65 +1820,63 @@ export default function App() { setScreen('routes')} + onBack={openHomeRoot} proofMediaResults={proofMediaResults} route={selectedRoute} /> ) : null} - + + )} + {shouldShowDriverBottomTabs(screen) ? ( + + + + ) : null} + {message !== null ? : null} - + ); } -function LoginScreen({ - acceptedLocation, - acceptedPrivacy, +function LoginPhoneScreen({ countrySearchQuery, driverPhoneCountries, - driverName, isCountrySelectorOpen, - isLoggingIn, + isSendingCode, nationalPhoneInput, - onAcceptedLocationChange, - onAcceptedPrivacyChange, onCountrySearchChange, onCountrySelect, onCountrySelectorToggle, - onDriverNameChange, onPhoneChange, onSendCode, - onSubmit, - onVerificationCodeChange, phoneE164Preview, selectedDriverLocale, selectedPhoneCountry, - verificationCode, }: { - acceptedLocation: boolean; - acceptedPrivacy: boolean; countrySearchQuery: string; driverPhoneCountries: DriverPhoneCountry[]; - driverName: string; isCountrySelectorOpen: boolean; - isLoggingIn: boolean; + isSendingCode: boolean; nationalPhoneInput: string; - onAcceptedLocationChange(value: boolean): void; - onAcceptedPrivacyChange(value: boolean): void; onCountrySearchChange(value: string): void; onCountrySelect(country: DriverPhoneCountry): void; onCountrySelectorToggle(): void; - onDriverNameChange(value: string): void; onPhoneChange(value: string): void; onSendCode(): void; - onSubmit(): void; - onVerificationCodeChange(value: string): void; phoneE164Preview: string | null; selectedDriverLocale: string; selectedPhoneCountry: DriverPhoneCountry; - verificationCode: string; }) { return ( @@ -915,39 +1902,208 @@ function LoginScreen({ onChangeText={onPhoneChange} value={nationalPhoneInput} /> + + + + ); +} + +function LoginDetailScreen({ + acceptedLocation, + acceptedPrivacy, + inviteCode, + isRegistration, + isLoggingIn, + onAcceptedLocationChange, + onAcceptedPrivacyChange, + onInviteCodeChange, + onModeChange, + onPinChange, + onPinConfirmationChange, + onSubmit, + pin, + pinConfirmation, +}: { + acceptedLocation: boolean; + acceptedPrivacy: boolean; + inviteCode: string; + isRegistration: boolean; + isLoggingIn: boolean; + onAcceptedLocationChange(value: boolean): void; + onAcceptedPrivacyChange(value: boolean): void; + onInviteCodeChange(value: string): void; + onModeChange(value: boolean): void; + onPinChange(value: string): void; + onPinConfirmationChange(value: string): void; + onSubmit(): void; + pin: string; + pinConfirmation: string; +}) { + return ( + + + Clever Driver + {isRegistration ? 'Create your driver account' : 'Enter your PIN'} + + + + {isRegistration ? ( + onInviteCodeChange(value.replace(/[^0-9a-f]/giu, '').slice(0, 6).toUpperCase())} + placeholder="6-character code" + returnKeyType="next" + value={inviteCode} + /> + ) : null} onPinChange(value.replace(/\D/gu, '').slice(0, 6))} + onSubmitEditing={isRegistration ? undefined : onSubmit} + placeholder="6 digits" + returnKeyType={isRegistration ? 'next' : 'done'} + secureTextEntry + value={pin} /> - - onPinConfirmationChange(value.replace(/\D/gu, '').slice(0, 6))} + onSubmitEditing={onSubmit} + placeholder="Enter the PIN again" + returnKeyType="done" + secureTextEntry + value={pinConfirmation} + /> + ) : null} + + + + + - onModeChange(!isRegistration)} /> - ); } -function RouteListScreen({ +function HomePage({ + allStopsCompleted, + company, completedStopIds, - driverName, + isStartingRoute, + onBrowseRoutes, + onContinueRoute, + onOpenRoutePreview, + onReviewNoteChange, + onStartRoute, + route, + routeReviewNote, + routeStatus, +}: { + allStopsCompleted: boolean; + company: RouteAccessCompanyGuidance | null; + completedStopIds: string[]; + isStartingRoute: boolean; + onBrowseRoutes(): void; + onContinueRoute(): void; + onOpenRoutePreview(): void; + onReviewNoteChange(value: string): void; + onStartRoute(): void; + route: AssignedRoute | null; + routeReviewNote: string; + routeStatus: RouteStatus; +}) { + if (route === null) { + return ( + + + Home + Your active route cockpit will appear here after you select an assigned route. + + + + + ); + } + + return ( + + + Home + This is your current route cockpit. + + + + + + {getInitials(company?.companyDisplayName ?? route.name)} + + + {company?.companyDisplayName ?? route.name} + {route.name} + + + + + + + + + + {routeStatus === 'completed' ? ( + + + + + ) : ( + + + + + )} + + + ); +} + +function RoutesPage({ + isRefreshingRoutes, isStartingRoute, onOpenCompletedDeliveries, - onOpenRouteDetail, + onOpenRoutePreview, + onContinueRoute, + onRefreshRoutes, onSelectRoute, onSelectTab, onStartRoute, @@ -957,11 +2113,12 @@ function RouteListScreen({ selectedTab, tabs, }: { - completedStopIds: string[]; - driverName: string; + isRefreshingRoutes: boolean; isStartingRoute: boolean; onOpenCompletedDeliveries(): void; - onOpenRouteDetail(routeId: string): void; + onOpenRoutePreview(routeId: string): void; + onContinueRoute(routeId: string): void; + onRefreshRoutes(): void; onSelectRoute(routeId: string): void; onSelectTab(tab: RouteTabId): void; onStartRoute(routeId: string): void; @@ -971,9 +2128,26 @@ function RouteListScreen({ selectedTab: RouteTabId; tabs: ReturnType; }) { - const visibleRouteSessions = routeSessions.filter((session) => getRouteSessionStatus(session.route.id, selectedRouteId, routeStatus) === selectedTab); + const classificationNow = new Date(); + const visibleRouteSessions = filterVisibleAssignedRouteSessions(routeSessions, { + now: classificationNow, + selectedRouteId, + selectedRouteStatus: routeStatus, + selectedTab, + }); const activeSession = visibleRouteSessions.find((session) => session.route.id === selectedRouteId) ?? visibleRouteSessions[0] ?? null; const activeIndex = activeSession === null ? -1 : visibleRouteSessions.findIndex((session) => session.route.id === activeSession.route.id); + const activeRouteStatusForTabs = activeSession === null + ? null + : classifyAssignedRouteSession({ + now: classificationNow, + route: activeSession.route, + selectedRouteId, + selectedRouteStatus: routeStatus, + }); + const [collapsedRouteKey, setCollapsedRouteKey] = useState(null); + const activeRouteCollapseKey = activeSession === null ? null : `${selectedTab}:${activeSession.route.id}`; + const isRouteCardExpanded = activeRouteCollapseKey === null || collapsedRouteKey !== activeRouteCollapseKey; function selectRelativeRoute(offset: number) { if (visibleRouteSessions.length === 0 || activeIndex < 0) { @@ -987,102 +2161,285 @@ function RouteListScreen({ return ( - Today’s Route - {driverName.trim() ? `${driverName.trim()}, your assigned route is ready.` : 'Your assigned route is ready.'} + Routes + View current and upcoming routes first. + + {selectedTab === 'completed' ? ( + + ) : null} {activeSession !== null ? ( - + { + setCollapsedRouteKey((value) => value === activeRouteCollapseKey ? null : activeRouteCollapseKey); + }} + style={styles.routeCardHeader} + > - {getInitials(activeSession.companyGuidance.companyDisplayName)} + {getInitials(activeSession.route.name)} - {activeSession.companyGuidance.companyDisplayName} - {activeSession.route.name} + {activeSession.route.name} + {activeSession.route.deliveryDate} - - - - - - - - - - - - {visibleRouteSessions.length > 1 ? ( - - selectRelativeRoute(-1)} /> - Route {activeIndex + 1} of {visibleRouteSessions.length} - selectRelativeRoute(1)} /> + + + {isRouteCardExpanded ? '−' : '+'} - ) : null} + - {selectedTab === 'completed' ? ( - - ) : selectedTab === 'active' ? ( - onOpenRouteDetail(activeSession.route.id)} /> - ) : ( - - onStartRoute(activeSession.route.id)} /> - onOpenRouteDetail(activeSession.route.id)} /> - - )} + {isRouteCardExpanded ? ( + <> + + + + + + + {visibleRouteSessions.length > 1 ? ( + + selectRelativeRoute(-1)} /> + Route {activeIndex + 1} of {visibleRouteSessions.length} + selectRelativeRoute(1)} /> + + ) : null} + + {selectedTab === 'completed' ? ( + + ) : selectedTab === 'active' ? ( + onContinueRoute(activeSession.route.id)} /> + ) : ( + + onStartRoute(activeSession.route.id)} /> + onOpenRoutePreview(activeSession.route.id)} /> + + )} + + ) : null} ) : ( 0 ? 'Completed stops are available after route completion.' : 'No route is available for this status.'} + body={selectedTab === 'completed' ? getDriverPlaceholderCopy('routeHistory') : 'No assigned route is available for this status.'} /> )} + + ); +} - +function EarningsPage() { + return ( + + + Earnings + Payouts and route earnings will appear here after the business rules and API are ready. + + ); } -function RouteDetailScreen({ - allStopsCompleted, - company, - completedStopIds, - continuousLocationResult, - deliveryFinishResult, - isFinishingRoute, - isStartingRoute, - onBack, - onFinishRoute, - onStartRoute, - route, - routeStartedEventResult, - routeStatus, +function ProfilePage({ + acceptedLocation, + acceptedPrivacy, + appVersion, + onLogout, + onRequestAccountDeletionInfo, + phoneE164, }: { - allStopsCompleted: boolean; - company: RouteAccessCompanyGuidance | null; - completedStopIds: string[]; - continuousLocationResult: ContinuousLocationStreamStartResult | ContinuousLocationStopResult | null; - deliveryFinishResult: DeliveryFinishResult | null; - isFinishingRoute: boolean; - isStartingRoute: boolean; - onBack(): void; - onFinishRoute(): void; - onStartRoute(): void; - route: AssignedRoute; - routeStartedEventResult: RouteStartedRecordResult | null; - routeStatus: RouteStatus; + acceptedLocation: boolean; + acceptedPrivacy: boolean; + appVersion: string; + onLogout(): void; + onRequestAccountDeletionInfo(): void; + phoneE164: string | null; }) { return ( - - - {company?.companyDisplayName ?? route.shopDomain} - + + Profile + Local driver session, consent, and app information. + + + + Driver information + + + + + + + Permissions & consent + + + OS location permission is requested when delivery starts. This page reviews the app consent versions accepted during login. + + + + Account + + + {getDriverPlaceholderCopy('accountDeletion')} + + + ); +} + + + +function RoutePreviewRegionBlock({ items }: { items: string[] }) { + return ( + + {ROUTE_PREVIEW_LABELS.region} + + {items.map((item) => ( + {item} + ))} + + + ); +} + +function RoutePreviewScreen({ + mapStyleUrl, + onBack, + onOpenMapPreview, + route, +}: { + mapStyleUrl: string; + onBack(): void; + onOpenMapPreview(): void; + route: AssignedRoute; +}) { + const previewSequence = buildRoutePreviewSequence(route); + + return ( + + + + + + + + + + + + + + + {ROUTE_PREVIEW_LABELS.map} + + + + + + + + + {ROUTE_PREVIEW_LABELS.sequence} + {previewSequence.items.length > 0 ? previewSequence.items.map((item) => ( + + {item.marker} + {item.address} + + )) : No stops assigned.} + {previewSequence.overflowCount > 0 ? ( + + {previewSequence.overflowCount} more stops + ) : null} + + + ); +} + +function RouteSessionScreen({ + allStopsCompleted, + company, + completedStopIds, + continuousLocationResult, + currentNavigationStepIndex, + deliveryFinishResult, + isFinishingRoute, + isStartingRoute, + isCompanyStep, + mapStyleUrl, + onArrived, + onBack, + onFinishRoute, + onOpenMapPreview, + onOpenNavigation, + onOpenStop, + onStartRoute, + onViewCurrentStop, + route, + routeStartedEventResult, + routeStatus, + stop, +}: { + allStopsCompleted: boolean; + company: RouteAccessCompanyGuidance | null; + completedStopIds: string[]; + continuousLocationResult: ContinuousLocationStreamStartResult | ContinuousLocationStopResult | null; + currentNavigationStepIndex: number; + deliveryFinishResult: DeliveryFinishResult | null; + isFinishingRoute: boolean; + isStartingRoute: boolean; + isCompanyStep: boolean; + mapStyleUrl: string; + onArrived(): void; + onBack(): void; + onFinishRoute(): void; + onOpenMapPreview(): void; + onOpenNavigation(): void; + onOpenStop(stop: AssignedRouteStop): void; + onStartRoute(): void; + onViewCurrentStop(): void; + route: AssignedRoute; + routeStartedEventResult: RouteStartedRecordResult | null; + routeStatus: RouteStatus; + stop: AssignedRouteStop | null; +}) { + const depotIsProcessing = routeStatus === 'active' && currentNavigationStepIndex === COMPANY_STEP_INDEX; + const depotMeta = depotIsProcessing ? 'Pickup' : routeStatus === 'completed' || currentNavigationStepIndex > COMPANY_STEP_INDEX ? 'Done' : undefined; + const depotMetaTone = depotIsProcessing ? 'blue' : 'green'; + const depotState = routeStatus === 'upcoming' || depotIsProcessing ? 'current' : 'completed'; + const currentTaskTitle = isCompanyStep ? 'Company Pickup' : stop === null ? 'Next Stop' : `Stop ${stop.sequence}`; + const currentTaskAddress = isCompanyStep ? company?.pickupGuidance ?? 'Pickup point' : stop === null ? 'Stop address' : formatStopAddress(stop); + const currentTaskPayment = stop === null ? null : formatAssignedRoutePaymentStatus(stop.normalizedPaymentStatus); + const primaryProgressAction = routeStatus === 'upcoming' + ? { disabled: isStartingRoute, label: 'Start Session', loading: isStartingRoute, onPress: onStartRoute } + : routeStatus === 'active' && allStopsCompleted + ? { disabled: isFinishingRoute, label: 'Finish Route', loading: isFinishingRoute, onPress: onFinishRoute } + : routeStatus === 'active' && isCompanyStep + ? { disabled: false, label: 'Complete Pickup', loading: false, onPress: onArrived } + : routeStatus === 'active' + ? { disabled: false, label: 'Mark as Arrived', loading: false, onPress: onArrived } + : null; + const showPrimaryActionInCurrentTask = routeStatus === 'active' && !allStopsCompleted && primaryProgressAction !== null; + + return ( + + + + {route.name} + - - + + @@ -1098,20 +2455,65 @@ function RouteDetailScreen({ ) : null} + {routeStatus === 'active' && !allStopsCompleted ? ( + + Current Task + + + {currentTaskTitle} + {currentTaskAddress} + + {currentTaskPayment !== null && currentTaskPayment.tone !== 'green' ? ( + + ) : null} + + + {showPrimaryActionInCurrentTask ? ( + + ) : null} + {routeStatus === 'active' && !isCompanyStep && stop !== null ? ( + + ) : null} + + + ) : null} + + + + Route Preview + Tap for full map + + + + + + + + Route Sequence - - {route.stops.map((stop) => { + + {route.stops.map((stop, index) => { const completed = completedStopIds.includes(stop.deliveryStopId); - const state = completed ? 'completed' : routeStatus === 'active' && !completed ? 'current' : 'upcoming'; + const isProcessing = routeStatus === 'active' && currentNavigationStepIndex === index + 1 && !completed; + const state = completed ? 'completed' : isProcessing ? 'current' : 'upcoming'; + const progressMeta = completed ? 'Done' : isProcessing ? 'Current' : undefined; + const metaTone = completed ? 'green' : isProcessing ? 'blue' : 'neutral'; return ( onOpenStop(stop)} /> ); })} @@ -1122,11 +2524,16 @@ function RouteDetailScreen({ {deliveryFinishResult?.flowState === 'delivery_finished' ? : null} - {routeStatus === 'upcoming' ? ( - - ) : routeStatus === 'active' && allStopsCompleted ? ( - + {primaryProgressAction !== null && !showPrimaryActionInCurrentTask ? ( + ) : null} + {routeStatus === 'active' ? : null} + @@ -1135,22 +2542,22 @@ function RouteDetailScreen({ function LiveTrackingScreen({ company, - continuousLocationResult, - currentStepIndex, isCompanyStep, onArrived, onBack, + onOpenMapPreview, + onOpenNavigation, onViewStop, route, routeStatus, stop, }: { company: RouteAccessCompanyGuidance | null; - continuousLocationResult: ContinuousLocationStreamStartResult | ContinuousLocationStopResult | null; - currentStepIndex: number; isCompanyStep: boolean; onArrived(): void; onBack(): void; + onOpenMapPreview(): void; + onOpenNavigation(): void; onViewStop(): void; route: AssignedRoute; routeStatus: RouteStatus; @@ -1158,75 +2565,122 @@ function LiveTrackingScreen({ }) { const stepLabel = isCompanyStep ? 'Company Pickup' : stop === null ? 'Next Stop' : `Stop ${stop.sequence}`; const address = isCompanyStep ? company?.pickupGuidance ?? 'Pickup guidance' : stop === null ? 'Stop address' : formatStopAddress(stop); - const trackingLabel = continuousLocationResult?.kind === 'streaming' || routeStatus === 'active' ? 'GPS tracking active' : 'GPS tracking pending'; + const payment = stop === null ? null : formatAssignedRoutePaymentStatus(stop.normalizedPaymentStatus); return ( - - {trackingLabel} - - - - Next Stop - {address} - - - - + + + + Map Preview + View route on map + + + Open - - - + + + + Delivery details + {address} + + + + + + + + {payment !== null ? ( + + Payment + - {stepLabel} + ) : null} + + + + + {stepLabel} + + + ); +} + +function LiveMapPreviewScreen({ + currentStepIndex, + mapStyleUrl, + onBack, + route, +}: { + currentStepIndex: number; + mapStyleUrl: string; + onBack(): void; + route: AssignedRoute; +}) { + return ( + + + + ); } function StopDetailsScreen({ - company, - onAnnounceTip, - onArrived, onBack, onCall, onMessage, + onOpenNavigation, stop, }: { - company: RouteAccessCompanyGuidance | null; - onAnnounceTip(): void; - onArrived(): void; onBack(): void; onCall(): void; onMessage(): void; + onOpenNavigation(): void; stop: AssignedRouteStop; }) { - const tip = getNavigationTip({ company, isCompanyStep: false, stop }); + const payment = formatAssignedRoutePaymentStatus(stop.normalizedPaymentStatus); return ( Stop {stop.sequence} - {formatStopAddress(stop)} - {stop.recipientName ?? 'Recipient / Location'} + {formatStopStreetAddress(stop)} + Payment + + + + + Items to drop + + {stop.items.map((item, itemIndex) => ( + + {formatAssignedRouteItemLine(item)} + + ))} + + Delivery Instructions - + Location Tips - + + - - - - ); } @@ -1235,32 +2689,28 @@ function ArrivalCheckScreen({ draft, isCapturingPhoto, isCompletingStop, - mediaResult, onAnnounceTip, onBack, - onCapturePhoto, + onAddPhoto, onCompleteStop, onDraftChange, - photoResult, proofResult, stop, }: { draft: StopProofDraft; isCapturingPhoto: boolean; isCompletingStop: boolean; - mediaResult?: ProofMediaUploadResult; onAnnounceTip(): void; onBack(): void; - onCapturePhoto(source: ProofPhotoCaptureSource): void; + onAddPhoto(): void; onCompleteStop(): void; onDraftChange(patch: Partial): void; - photoResult?: ProofPhotoCaptureResult; proofResult?: StopProofEventResult; stop: AssignedRouteStop; }) { return ( - + @@ -1269,32 +2719,26 @@ function ArrivalCheckScreen({ - Photo Proof - - onCapturePhoto('camera')} /> - onCapturePhoto('library')} /> - {photoResult?.kind === 'captured' ? 'Proof Ready' : 'Proof Item'} - - {photoResult !== undefined ? : null} - {mediaResult !== undefined ? : null} + Delivery Photo + onDraftChange({ todayNote: value })} - placeholder="Select an issue" + placeholder="e.g. Left at front door" value={draft.todayNote} /> onDraftChange({ locationTip: value })} - placeholder="Add or select a delivery tip" + placeholder="e.g. Side entrance, gate code, parking note" value={draft.locationTip} /> onDraftChange({ additionalNotes: value })} - placeholder="Add any additional notes here…" + placeholder="Anything else for this stop" value={draft.additionalNotes} /> {proofResult !== undefined ? : null} @@ -1338,7 +2782,7 @@ function StopCompletedScreen({ Route progress - + ); @@ -1363,7 +2807,7 @@ function CompletedDeliveriesScreen({ - Today + Current session {route.deliveryDate} @@ -1401,12 +2845,35 @@ function CompletedDeliveriesScreen({ ); } -function ScreenHeader({ onBack, rightLabel, title }: { onBack?(): void; rightLabel?: string; title: string }) { +function ScreenHeader({ + hideRightAction = false, + onBack, + onRightPress, + rightLabel, + title, +}: { + hideRightAction?: boolean; + onBack?(): void; + onRightPress?(): void; + rightLabel?: string; + title: string; +}) { + const rightContent = rightLabel ?? 'Menu'; + const rightSlot = hideRightAction ? ( + + ) : onRightPress === undefined ? ( + {rightContent} + ) : ( + + {rightContent} + + ); + return ( {onBack === undefined ? : Back} {title} - {rightLabel ?? 'Menu'} + {rightSlot} ); } @@ -1527,22 +2994,38 @@ function PhoneNumberInput({ } function LabeledInput({ + blurOnSubmit, + inputAccessoryViewID, + inputRef, keyboardType, label, + maxLength, multiline, onChangeText, + onFocus, onRightAction, + onSubmitEditing, placeholder, rightActionLabel, + returnKeyType, + secureTextEntry, value, }: { - keyboardType?: 'default' | 'phone-pad'; + blurOnSubmit?: boolean; + inputAccessoryViewID?: string; + inputRef?: (input: TextInput | null) => void; + keyboardType?: 'default' | 'number-pad' | 'phone-pad'; label: string; + maxLength?: number; multiline?: boolean; onChangeText(value: string): void; + onFocus?(): void; onRightAction?(): void; + onSubmitEditing?(): void; placeholder: string; rightActionLabel?: string; + returnKeyType?: 'done' | 'next'; + secureTextEntry?: boolean; value: string; }) { return ( @@ -1552,11 +3035,19 @@ function LabeledInput({ @@ -1571,18 +3062,22 @@ function LabeledInput({ } function ConsentRow({ label, linkLabel, onValueChange, value }: { label: string; linkLabel: string; onValueChange(value: boolean): void; value: boolean }) { + const checkboxVisualState = getConsentCheckboxVisualState(value); + return ( - onValueChange(!value)} style={styles.consentRow}> - + onValueChange(!value)} style={styles.consentRow}> + + {checkboxVisualState.checkmark !== null ? {checkboxVisualState.checkmark} : null} + {label} {linkLabel} ); } -function PrimaryButton({ disabled, label, loading, onPress }: { disabled?: boolean; label: string; loading?: boolean; onPress(): void }) { +function PrimaryButton({ compact, disabled, label, loading, onPress }: { compact?: boolean; disabled?: boolean; label: string; loading?: boolean; onPress(): void }) { return ( - - {loading === true ? : {label}} + + {loading === true ? : {label}} ); } @@ -1590,7 +3085,7 @@ function PrimaryButton({ disabled, label, loading, onPress }: { disabled?: boole function SecondaryButton({ compact, disabled, label, loading, onPress }: { compact?: boolean; disabled?: boolean; label: string; loading?: boolean; onPress(): void }) { return ( - {loading === true ? : {label}} + {loading === true ? : {label}} ); } @@ -1613,7 +3108,7 @@ function MetricBlock({ label, tone, value }: { label: string; tone?: 'green' | ' ); } -function StatusChip({ label, tone }: { label: string; tone: 'blue' | 'green' | 'neutral' | 'warning' }) { +function StatusChip({ compact, label, tone }: { compact?: boolean; label: string; tone: 'blue' | 'green' | 'neutral' | 'warning' }) { const toneStyle = tone === 'blue' ? styles.statusChipBlue : tone === 'green' @@ -1621,49 +3116,332 @@ function StatusChip({ label, tone }: { label: string; tone: 'blue' | 'green' | ' : tone === 'warning' ? styles.statusChipWarning : styles.statusChipNeutral; - return {label}; + return {label}; } -function TimelineRow({ marker, meta, state, subtitle, title }: { marker: string; meta: string; state: 'completed' | 'current' | 'upcoming'; subtitle: string; title: string }) { - return ( - +function TimelineRow({ + marker, + meta, + metaTone = 'neutral', + onPress, + state, + subtitle, + title, +}: { + marker: string; + meta?: string; + metaTone?: 'blue' | 'green' | 'neutral'; + onPress?: () => void; + state: 'completed' | 'current' | 'upcoming'; + subtitle?: string; + title: string; +}) { + const content = ( + <> {marker} - {title} - {subtitle} + {title} + {subtitle !== undefined ? {subtitle} : null} - {meta} - + {meta !== undefined ? : null} + ); + + if (onPress !== undefined) { + return ( + [ + styles.timelineRow, + state === 'current' && styles.timelineRowCurrent, + pressed && { opacity: 0.88 }, + ]} + > + {content} + + ); + } + + return {content}; } -function MapOverview({ currentStepIndex, route }: { currentStepIndex: number; route: AssignedRoute }) { +function MapOverview({ + currentStepIndex, + mapSize = 'preview', + mapStyleUrl, + route, +}: { + currentStepIndex: number; + mapSize?: 'full' | 'live' | 'preview'; + mapStyleUrl: string; + route: AssignedRoute; +}) { + const previewKey = route.routeMapPreview?.imageUrl ?? null; + const interactiveMapKey = `${mapStyleUrl}:${route.id}:${route.routeGeometry?.coordinates.length ?? 0}`; + const [previewLoadState, setPreviewLoadState] = useState<{ key: string | null; status: 'failed' } | null>(null); + const [interactiveMapState, setInteractiveMapState] = useState<{ key: string; status: 'failed' } | null>(null); + const previewLoadStatus = previewLoadState?.key === previewKey ? previewLoadState.status : 'idle'; + const interactiveMapStatus = interactiveMapState?.key === interactiveMapKey ? interactiveMapState.status : 'idle'; + const previewState = resolveRouteMapPreviewState({ + loadStatus: previewLoadStatus, + now: new Date(), + preview: route.routeMapPreview, + }); + + const handleInteractiveMapUnavailable = useCallback(() => { + setInteractiveMapState({ key: interactiveMapKey, status: 'failed' }); + }, [interactiveMapKey]); + const canvasStyle = [ + styles.mapCanvas, + mapSize === 'live' ? styles.liveMapCanvas : null, + mapSize === 'full' ? styles.fullMapCanvas : null, + ]; + + if (interactiveMapStatus === 'idle' && route.routeGeometry !== null && route.routeGeometry.coordinates.length >= 2) { + return ( + + + + ); + } + + if (previewState.kind === 'available') { + return ( + + setPreviewLoadState({ key: previewKey, status: 'failed' })} + resizeMode="contain" + source={{ uri: previewState.imageUrl }} + style={styles.mapPreviewImage} + /> + + Route preview + + + ); + } + return ( - + - - {route.stops.slice(0, 3).map((stop, index) => ( - - {stop.sequence} + {route.stops.slice(0, 3).map((stop, index) => { + const isCurrentStop = currentStepIndex === index + 1; + + return ( + + {stop.sequence} + + ); + })} + {currentStepIndex >= route.stops.length ? 'Last' : currentStepIndex === COMPANY_STEP_INDEX ? 'Pickup' : `Stop ${currentStepIndex}`} + + Route preview + {previewState.message} + + + ); +} + +function DeliveryPhotoActionSheet({ + disabled, + onCancel, + onSelectSource, + visible, +}: { + disabled?: boolean; + onCancel(): void; + onSelectSource(source: ProofPhotoCaptureSource): void; + visible: boolean; +}) { + const { bottom: bottomInset } = useSafeAreaInsets(); + const bottomChromePadding = getBottomChromePadding(bottomInset); + + if (!visible) { + return null; + } + + return ( + + + + + Add Photo + + onSelectSource('camera')} + style={[styles.photoActionSheetAction, disabled === true && styles.buttonDisabled]} + > + Take Photo + + onSelectSource('library')} + style={[styles.photoActionSheetAction, disabled === true && styles.buttonDisabled]} + > + Choose from Album + - ))} - {currentStepIndex >= route.stops.length ? 'Last' : 'Next'} + + Cancel + + ); } -function ProofTile({ disabled, label, loading, onPress }: { disabled?: boolean; label: string; loading?: boolean; onPress(): void }) { +function ProofCameraScreen({ + disabled, + onCancel, + onCaptured, + onOpenGallery, +}: { + disabled?: boolean; + onCancel(): void; + onCaptured(uri: string): void; + onOpenGallery(): void; +}) { + const { bottom: bottomInset } = useSafeAreaInsets(); + const cameraRef = useRef(null); + const [permission, requestPermission] = useCameraPermissions(); + const [cameraError, setCameraError] = useState(null); + const [flashMode, setFlashMode] = useState<'off' | 'on'>('off'); + const [isCameraReady, setIsCameraReady] = useState(false); + const [isTakingPhoto, setIsTakingPhoto] = useState(false); + + useEffect(() => { + if (permission !== null) { + return; + } + + void requestPermission(); + }, [permission, requestPermission]); + + async function handleTakePhoto() { + if (disabled === true || isTakingPhoto || !isCameraReady) { + return; + } + + setIsTakingPhoto(true); + try { + const photo = await cameraRef.current?.takePictureAsync({ quality: 0.7 }); + if (photo?.uri !== undefined && photo.uri.trim() !== '') { + onCaptured(photo.uri); + return; + } + + setCameraError('Could not save this photo. Try again.'); + } catch (error) { + const message = error instanceof Error ? error.message : 'Camera capture failed.'; + console.warn(`[proof-camera] ${message}`); + setCameraError(message); + } finally { + setIsTakingPhoto(false); + } + } + + if (permission?.granted !== true) { + return ( + + Camera access needed + Allow camera access to take a delivery photo. + { + void requestPermission(); + }} + /> + + + ); + } + return ( - - {loading === true ? : {label}} - + + setIsCameraReady(true)} + onMountError={(event) => { + const message = event.message || 'Camera could not start.'; + console.warn(`[proof-camera] ${message}`); + setCameraError(message); + }} + ref={cameraRef} + style={styles.proofCameraPreview} + /> + + + + + + + × + + + Please make sure the package and surrounding location are clearly visible. + + + + + + + + + {cameraError !== null ? {cameraError} : null} + + + Gallery + + { + void handleTakePhoto(); + }} + style={[styles.proofCameraCaptureButton, (disabled === true || isTakingPhoto || !isCameraReady) && styles.buttonDisabled]} + > + + + setFlashMode((current) => current === 'off' ? 'on' : 'off')} + style={[styles.proofCameraSideButton, flashMode === 'on' && styles.proofCameraSideButtonActive]} + > + Flash + + + + This photo will be used as proof of delivery. + + ); } @@ -1702,17 +3480,39 @@ function EmptyState({ body, title }: { body: string; title: string }) { ); } -function BottomNavigation({ selected }: { selected: 'Earnings' | 'Home' | 'Profile' | 'Routes' }) { - const items = ['Home', 'Routes', 'Earnings', 'Profile'] as const; +function BottomNavigation({ + items, + onSelect, + selected, +}: { + items: ReturnType; + onSelect(tab: DriverMainTabId): void; + selected: DriverMainTabId; +}) { return ( - {items.map((item) => {item})} + {items.map((item) => { + const isSelected = item.id === selected; + return ( + onSelect(item.id)} + style={[styles.bottomNavItem, isSelected && styles.bottomNavItemSelected]} + > + {item.label} + + ); + })} ); } function getDriverConsentServiceForCurrentSubmission(input: { fallback: DriverConsentService; + refreshDriverAccess?: () => Promise; runtimeConfig: ReturnType; submission: RouteAccessSubmissionResult | null; }): DriverConsentService { @@ -1722,12 +3522,14 @@ function getDriverConsentServiceForCurrentSubmission(input: { return createDriverApiClientsFromRouteAccess({ baseUrl: input.runtimeConfig.deliveryServerBaseUrl, + refreshDriverAccess: input.refreshDriverAccess, routeAccess: toInvitedRouteAccess(input.submission), }).driverConsentService; } function getAssignedRouteServiceForCurrentSubmission(input: { fallback: AssignedRouteService; + refreshDriverAccess?: () => Promise; runtimeConfig: ReturnType; submission: RouteAccessSubmissionResult | null; }): AssignedRouteService { @@ -1737,12 +3539,14 @@ function getAssignedRouteServiceForCurrentSubmission(input: { return createDriverApiClientsFromRouteAccess({ baseUrl: input.runtimeConfig.deliveryServerBaseUrl, + refreshDriverAccess: input.refreshDriverAccess, routeAccess: toInvitedRouteAccess(input.submission), }).assignedRouteService; } function getDriverEventServiceForCurrentSubmission(input: { fallback: DriverEventService; + refreshDriverAccess?: () => Promise; runtimeConfig: ReturnType; submission: RouteAccessSubmissionResult | null; }): DriverEventService { @@ -1752,12 +3556,14 @@ function getDriverEventServiceForCurrentSubmission(input: { return createDriverApiClientsFromRouteAccess({ baseUrl: input.runtimeConfig.deliveryServerBaseUrl, + refreshDriverAccess: input.refreshDriverAccess, routeAccess: toInvitedRouteAccess(input.submission), }).driverEventService; } function getProofMediaUploadServiceForCurrentSubmission(input: { fallback: ProofMediaUploadService; + refreshDriverAccess?: () => Promise; runtimeConfig: ReturnType; submission: RouteAccessSubmissionResult | null; }): ProofMediaUploadService { @@ -1767,6 +3573,7 @@ function getProofMediaUploadServiceForCurrentSubmission(input: { return createDriverApiClientsFromRouteAccess({ baseUrl: input.runtimeConfig.deliveryServerBaseUrl, + refreshDriverAccess: input.refreshDriverAccess, routeAccess: toInvitedRouteAccess(input.submission), }).proofMediaUploadService; } @@ -1813,12 +3620,8 @@ function getRouteSessionForAction(routeSessions: RouteSession[], routeId: string return routeSessions[0] ?? null; } -function getRouteSessionStatus(routeId: string, selectedRouteId: string | null, selectedRouteStatus: RouteStatus): RouteStatus { - return routeId === selectedRouteId ? selectedRouteStatus : 'upcoming'; -} - function formatRouteAccessProblem(result: RouteAccessSubmissionResult): string { - if (result.kind === 'validation_error' || result.kind === 'denied' || result.kind === 'multiple_matches') { + if (result.kind === 'denied' || result.kind === 'multiple_matches') { return result.message; } @@ -1836,6 +3639,23 @@ function formatDriverPhoneEntryProblem(reason: 'country_required' | 'phone_inval } } +function getRestoredActiveDeliveryStartResult(): DeliveryStartResult { + return { + flowState: 'delivery_active', + kind: 'delivery_active', + locationPermission: 'foreground', + message: 'Active route session restored on this device.', + }; +} + +function clampRouteNavigationStepIndex(stepIndex: number, route: AssignedRoute): number { + if (!Number.isInteger(stepIndex)) { + return COMPANY_STEP_INDEX; + } + + return Math.min(Math.max(stepIndex, COMPANY_STEP_INDEX), route.stops.length); +} + function getRouteStatus(deliveryStartResult: DeliveryStartResult | null, deliveryFinishResult: DeliveryFinishResult | null): RouteStatus { if (deliveryFinishResult?.flowState === 'delivery_finished') { return 'completed'; @@ -1854,6 +3674,8 @@ function formatRouteStatus(status: RouteStatus): string { return 'In progress'; case 'completed': return 'Completed'; + case 'unfinished': + return 'Unfinished'; case 'upcoming': return 'Pending'; } @@ -1878,6 +3700,15 @@ function formatStopAddress(stop: AssignedRouteStop): string { .join(', '); } +function formatStopStreetAddress(stop: AssignedRouteStop): string { + const streetAddress = [stop.address.address1, stop.address.address2] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(', '); + + return streetAddress.length === 0 ? formatStopAddress(stop) : streetAddress; +} + function getNavigationTip(input: { company: RouteAccessCompanyGuidance | null; isCompanyStep: boolean; @@ -1905,45 +3736,29 @@ function getProofDraft(draft?: StopProofDraft): StopProofDraft { function formatStopProofNote(draft: StopProofDraft): string { return [ - draft.todayNote.trim().length > 0 ? `Delivery note: ${draft.todayNote.trim()}` : null, + draft.todayNote.trim().length > 0 ? `Delivery result: ${draft.todayNote.trim()}` : null, draft.locationTip.trim().length > 0 ? `Location tip: ${draft.locationTip.trim()}` : null, - draft.additionalNotes.trim().length > 0 ? `Additional notes: ${draft.additionalNotes.trim()}` : null, + draft.additionalNotes.trim().length > 0 ? `Other notes: ${draft.additionalNotes.trim()}` : null, ] .filter((value): value is string => value !== null) .join('\n') || 'Photo proof submitted.'; } -function formatPhotoResult(captureResult: ProofPhotoCaptureResult, uploadResult: ProofMediaUploadResult): string { - return `${formatPhotoCaptureResult(captureResult)} ${formatMediaUploadResult(uploadResult)}`.trim(); -} - -function formatPhotoCaptureResult(result: ProofPhotoCaptureResult): string { - if (result.kind === 'captured') { - return `Photo proof attached from ${result.source}.`; - } - - if (result.kind === 'cancelled') { - return 'Photo selection was cancelled.'; - } - - return result.message; -} - -function formatMediaUploadResult(result: ProofMediaUploadResult): string { - if (result.kind === 'uploaded') { - return `Proof uploaded: ${result.media.mediaId}`; +function formatPhotoResult(captureResult: ProofPhotoCaptureResult, uploadResult: ProofMediaUploadResult): string | null { + if (captureResult.kind !== 'captured') { + return captureResult.kind === 'cancelled' ? null : captureResult.message; } - return result.message; + return uploadResult.kind === 'uploaded' ? null : uploadResult.message; } function formatStopProofResult(result: StopProofEventResult): string { if (result.kind === 'recorded') { - return `Stop completion recorded: ${result.eventId}`; + return 'Stop completed.'; } if (result.kind === 'queued') { - return `Saved to offline queue: ${result.queueItemId}`; + return 'Saved offline. It will sync when connected.'; } return result.message; @@ -1967,6 +3782,8 @@ function getChipTone(status: RouteStatus): 'blue' | 'green' | 'neutral' { return 'blue'; case 'completed': return 'green'; + case 'unfinished': + return 'neutral'; case 'upcoming': return 'neutral'; } @@ -1976,15 +3793,6 @@ function formatStopCount(count: number): string { return `${count} stop${count === 1 ? '' : 's'}`; } -function formatRouteSequence(route: AssignedRoute): string { - if (route.stops.length === 0) { - return 'Depot'; - } - - const stopMarkers = route.stops.map((stop, index) => (index === route.stops.length - 1 ? 'Last' : String(stop.sequence))); - return ['Depot', ...stopMarkers].join(' → '); -} - function getInitials(value: string): string { const initials = value .split(/[\s.-]+/u) @@ -2033,14 +3841,17 @@ const styles = StyleSheet.create({ safeArea: { backgroundColor: '#f7f9fc', flex: 1, + position: 'relative', }, keyboardArea: { flex: 1, }, container: { + flexGrow: 1, gap: 22, padding: 22, - paddingBottom: 36, + paddingBottom: 28, + paddingTop: 34, }, screenStack: { gap: 22, @@ -2227,24 +4038,35 @@ const styles = StyleSheet.create({ fontWeight: '800', paddingLeft: 10, }, + consentStack: { + gap: 6, + }, consentRow: { alignItems: 'center', flexDirection: 'row', - gap: 12, - minHeight: 48, + gap: 8, + minHeight: 38, }, checkboxBox: { + alignItems: 'center', backgroundColor: '#ffffff', borderColor: '#cfd6e4', borderRadius: 6, borderWidth: 1.5, - height: 24, - width: 24, + height: 22, + justifyContent: 'center', + width: 22, }, checkboxBoxSelected: { backgroundColor: '#0b57d0', borderColor: '#0b57d0', }, + checkboxCheckmark: { + color: '#ffffff', + fontSize: 15, + fontWeight: '900', + lineHeight: 17, + }, consentText: { color: '#111827', flex: 1, @@ -2286,6 +4108,10 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, paddingVertical: 8, }, + compactButtonText: { + fontSize: 14, + fontWeight: '600', + }, secondaryButtonText: { color: '#0b57d0', fontSize: 16, @@ -2302,6 +4128,9 @@ const styles = StyleSheet.create({ buttonColumn: { gap: 12, }, + trackingButtonColumn: { + gap: 12, + }, tabs: { backgroundColor: '#ffffff', borderColor: '#d9dee8', @@ -2319,7 +4148,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 8, }, tabActive: { - backgroundColor: '#0b57d0', + backgroundColor: '#eef6ff', + borderColor: '#bfdbfe', + borderWidth: 1, }, tabText: { color: '#475467', @@ -2328,7 +4159,8 @@ const styles = StyleSheet.create({ textAlign: 'center', }, tabTextActive: { - color: '#ffffff', + color: '#0b57d0', + fontWeight: '800', }, selectedRouteCard: { backgroundColor: '#ffffff', @@ -2362,6 +4194,17 @@ const styles = StyleSheet.create({ flex: 1, gap: 4, }, + routeCardStatusGroup: { + alignItems: 'center', + flexDirection: 'row', + gap: 8, + }, + routeToggleText: { + color: '#475467', + fontSize: 20, + fontWeight: '800', + lineHeight: 22, + }, cardTitle: { color: '#111827', fontSize: 19, @@ -2403,6 +4246,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, paddingVertical: 6, }, + statusChipCompact: { + fontSize: 11, + paddingHorizontal: 8, + paddingVertical: 4, + }, statusChipBlue: { backgroundColor: '#e8f1ff', color: '#0b57d0', @@ -2431,24 +4279,44 @@ const styles = StyleSheet.create({ fontWeight: '800', textAlign: 'center', }, + bottomNavArea: { + backgroundColor: '#f7f9fc', + paddingBottom: 8, + paddingHorizontal: 18, + paddingTop: 8, + }, bottomNav: { alignItems: 'center', backgroundColor: '#ffffff', - borderColor: '#eef2f6', - borderRadius: 18, - borderWidth: 1, + borderColor: '#dbeafe', + borderRadius: 22, + borderWidth: 1.4, flexDirection: 'row', - justifyContent: 'space-around', - minHeight: 62, - paddingHorizontal: 10, + gap: 8, + justifyContent: 'space-between', + minHeight: 68, + paddingHorizontal: 8, + paddingVertical: 8, + ...shadow, + }, + bottomNavItem: { + alignItems: 'center', + borderRadius: 16, + flex: 1, + justifyContent: 'center', + minHeight: 50, + paddingHorizontal: 8, + }, + bottomNavItemSelected: { + backgroundColor: '#0b57d0', }, bottomNavLabel: { color: '#667085', fontSize: 12, - fontWeight: '700', + fontWeight: '800', }, bottomNavLabelSelected: { - color: '#0b57d0', + color: '#ffffff', fontWeight: '900', }, screenHeader: { @@ -2534,6 +4402,37 @@ const styles = StyleSheet.create({ fontSize: 15, fontWeight: '800', }, + paymentInlineRow: { + alignItems: 'center', + borderColor: '#eef2f6', + borderRadius: 14, + borderWidth: 1, + flexDirection: 'row', + justifyContent: 'space-between', + padding: 12, + }, + paymentBadgeOnlyPanel: { + alignItems: 'flex-start', + backgroundColor: '#ffffff', + borderColor: '#e5e7eb', + borderRadius: 16, + borderWidth: 1, + padding: 14, + }, + stopItemsPanel: { + backgroundColor: '#ffffff', + borderColor: '#e5e7eb', + borderRadius: 16, + borderWidth: 1, + gap: 8, + padding: 14, + }, + stopItemLine: { + color: '#111827', + fontSize: 15, + fontWeight: '800', + lineHeight: 22, + }, timelineCard: { backgroundColor: '#ffffff', borderColor: '#e5e7eb', @@ -2542,6 +4441,89 @@ const styles = StyleSheet.create({ gap: 8, padding: 16, }, + routePreviewCard: { + backgroundColor: '#ffffff', + borderColor: '#e5e7eb', + borderRadius: 18, + borderWidth: 1, + gap: 10, + padding: 16, + }, + routePreviewHeader: { + alignItems: 'baseline', + flexDirection: 'row', + gap: 8, + }, + routePreviewHint: { + color: '#667085', + fontSize: 12, + fontWeight: '700', + }, + routePreviewRegionBlock: { + borderBottomColor: '#eef2f6', + borderBottomWidth: 1, + gap: 4, + paddingBottom: 8, + }, + routePreviewRegionLabel: { + color: '#667085', + fontSize: 12, + fontWeight: '400', + }, + routePreviewRegionList: { + gap: 2, + }, + routePreviewRegionItem: { + color: '#475467', + fontSize: 13, + fontWeight: '400', + lineHeight: 18, + }, + routePreviewCanvas: { + borderRadius: 16, + overflow: 'hidden', + }, + routePreviewSequenceRow: { + alignItems: 'flex-start', + flexDirection: 'row', + gap: 10, + }, + routePreviewSequenceMarker: { + backgroundColor: '#eef2f6', + borderRadius: 999, + color: '#475467', + fontSize: 12, + fontWeight: '800', + minWidth: 28, + overflow: 'hidden', + paddingHorizontal: 8, + paddingVertical: 5, + textAlign: 'center', + }, + routePreviewSequenceAddress: { + color: '#111827', + flex: 1, + fontSize: 14, + fontWeight: '700', + lineHeight: 20, + }, + currentTaskCard: { + backgroundColor: '#ffffff', + borderColor: '#bfdbfe', + borderRadius: 18, + borderWidth: 1.4, + gap: 14, + padding: 16, + }, + currentTaskActions: { + gap: 8, + }, + currentTaskAddressText: { + color: '#374151', + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, timelineRow: { alignItems: 'center', borderRadius: 14, @@ -2577,35 +4559,42 @@ const styles = StyleSheet.create({ color: '#ffffff', }, timelineTitle: { - color: '#111827', + color: '#344054', fontSize: 15, - fontWeight: '800', + fontWeight: '400', + lineHeight: 20, + }, + timelineTitleCurrent: { + color: '#111827', + fontWeight: '700', }, timelineMeta: { color: '#475467', fontSize: 12, fontWeight: '800', }, - mapPanel: { - backgroundColor: '#eef5f8', - borderRadius: 22, - minHeight: 660, + trackingDetailsPage: { + backgroundColor: '#ffffff', + borderColor: '#dbeafe', + borderRadius: 24, + borderWidth: 1, + gap: 16, + padding: 20, + ...shadow, + }, + fullScreenMap: { + flex: 1, overflow: 'hidden', }, - gpsPill: { - alignItems: 'center', - alignSelf: 'center', - backgroundColor: '#ffffff', - borderRadius: 14, - flexDirection: 'row', - gap: 9, - marginTop: 12, - paddingHorizontal: 20, - paddingVertical: 12, + fullScreenMapHeader: { + left: 0, + paddingHorizontal: 22, + paddingBottom: 12, + paddingTop: 34, position: 'absolute', + right: 0, top: 0, - zIndex: 4, - ...shadow, + zIndex: 2, }, statusDot: { backgroundColor: '#12b76a', @@ -2613,16 +4602,270 @@ const styles = StyleSheet.create({ height: 12, width: 12, }, - gpsPillText: { - color: '#087443', - fontSize: 14, - fontWeight: '800', - }, mapCanvas: { backgroundColor: '#f3f8fb', height: 430, + overflow: 'hidden', position: 'relative', }, + liveMapCanvas: { + height: 540, + }, + fullMapCanvas: { + flex: 1, + height: '100%', + }, + proofCameraScreen: { + backgroundColor: '#000000', + flex: 1, + }, + proofCameraPreview: { + flex: 1, + }, + proofCameraPermissionScreen: { + flex: 1, + gap: 14, + justifyContent: 'center', + padding: 22, + }, + proofCameraTopBar: { + left: 0, + position: 'absolute', + right: 0, + top: 22, + zIndex: 3, + }, + proofCameraCloseButton: { + alignItems: 'center', + height: 40, + justifyContent: 'center', + left: 14, + position: 'absolute', + top: 0, + width: 40, + }, + proofCameraCloseText: { + color: '#ffffff', + fontSize: 34, + fontWeight: '300', + lineHeight: 36, + }, + proofCameraInstructionCard: { + alignItems: 'center', + backgroundColor: 'rgba(8, 8, 8, 0.66)', + borderRadius: 10, + flexDirection: 'row', + left: 34, + minHeight: 50, + paddingHorizontal: 12, + paddingVertical: 9, + position: 'absolute', + right: 34, + top: 46, + }, + proofCameraInstructionText: { + color: '#ffffff', + flex: 1, + fontSize: 13, + fontWeight: '700', + lineHeight: 18, + textAlign: 'left', + }, + proofCameraDimTop: { + backgroundColor: 'rgba(0, 0, 0, 0.42)', + height: '21%', + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + proofCameraDimBottom: { + backgroundColor: 'rgba(0, 0, 0, 0.42)', + bottom: 0, + height: '27%', + left: 0, + position: 'absolute', + right: 0, + }, + proofCameraDimLeft: { + backgroundColor: 'rgba(0, 0, 0, 0.42)', + bottom: '27%', + left: 0, + position: 'absolute', + top: '21%', + width: 34, + }, + proofCameraDimRight: { + backgroundColor: 'rgba(0, 0, 0, 0.42)', + bottom: '27%', + position: 'absolute', + right: 0, + top: '21%', + width: 34, + }, + proofCameraGuide: { + borderColor: 'rgba(255, 255, 255, 0.7)', + borderWidth: 1, + bottom: '27%', + left: 34, + position: 'absolute', + right: 34, + top: '21%', + zIndex: 2, + }, + proofCameraGuideCorner: { + borderColor: '#ffffff', + height: 42, + position: 'absolute', + width: 42, + }, + proofCameraGuideCornerTopLeft: { + borderLeftWidth: 4, + borderTopWidth: 4, + left: -2, + top: -2, + }, + proofCameraGuideCornerTopRight: { + borderRightWidth: 4, + borderTopWidth: 4, + right: -2, + top: -2, + }, + proofCameraGuideCornerBottomLeft: { + borderBottomWidth: 4, + borderLeftWidth: 4, + bottom: -2, + left: -2, + }, + proofCameraGuideCornerBottomRight: { + borderBottomWidth: 4, + borderRightWidth: 4, + bottom: -2, + right: -2, + }, + proofCameraControls: { + alignItems: 'center', + bottom: 66, + flexDirection: 'row', + justifyContent: 'space-between', + left: 36, + position: 'absolute', + right: 36, + zIndex: 3, + }, + proofCameraSideButton: { + alignItems: 'center', + backgroundColor: 'rgba(12, 12, 12, 0.62)', + borderRadius: 999, + minWidth: 68, + paddingHorizontal: 12, + paddingVertical: 9, + }, + proofCameraSideButtonActive: { + opacity: 0.82, + }, + proofCameraSideButtonText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '700', + lineHeight: 16, + }, + proofCameraCaptureButton: { + alignItems: 'center', + backgroundColor: 'transparent', + borderColor: '#ffffff', + borderRadius: 999, + borderWidth: 4, + height: 68, + justifyContent: 'center', + width: 68, + }, + proofCameraCaptureInner: { + backgroundColor: '#ffffff', + borderRadius: 999, + height: 52, + width: 52, + }, + proofCameraErrorText: { + alignSelf: 'center', + backgroundColor: 'rgba(127, 29, 29, 0.82)', + borderRadius: 999, + color: '#ffffff', + fontSize: 13, + fontWeight: '700', + overflow: 'hidden', + paddingHorizontal: 12, + paddingVertical: 8, + position: 'absolute', + bottom: 146, + textAlign: 'center', + zIndex: 4, + }, + proofCameraFooter: { + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.78)', + bottom: 0, + flexDirection: 'row', + justifyContent: 'center', + left: 0, + paddingBottom: 12, + paddingHorizontal: 18, + paddingTop: 10, + position: 'absolute', + right: 0, + zIndex: 2, + }, + proofCameraFooterText: { + color: '#ffffff', + flexShrink: 1, + fontSize: 12, + fontWeight: '600', + lineHeight: 17, + }, + mapPreviewImage: { + height: '100%', + width: '100%', + }, + mapPreviewBadge: { + backgroundColor: 'rgba(12, 18, 32, 0.76)', + borderRadius: 999, + left: 16, + paddingHorizontal: 12, + paddingVertical: 7, + position: 'absolute', + top: 16, + }, + mapPreviewBadgeText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '900', + letterSpacing: 0.2, + textTransform: 'uppercase', + }, + mapPreviewFallback: { + backgroundColor: 'rgba(255, 255, 255, 0.92)', + borderColor: '#d0d5dd', + borderRadius: 18, + borderWidth: 1, + bottom: 24, + left: 18, + paddingHorizontal: 16, + paddingVertical: 14, + position: 'absolute', + right: 18, + }, + mapPreviewFallbackTitle: { + color: '#101828', + fontSize: 15, + fontWeight: '900', + marginBottom: 4, + }, + mapPreviewFallbackText: { + color: '#475467', + fontSize: 13, + fontWeight: '700', + lineHeight: 18, + }, mapBlock: { backgroundColor: '#dff3e8', borderRadius: 10, @@ -2703,11 +4946,19 @@ const styles = StyleSheet.create({ mapMarker: { alignItems: 'center', backgroundColor: '#0b57d0', - borderRadius: 17, - height: 34, + borderRadius: 15, + height: 30, justifyContent: 'center', position: 'absolute', - width: 34, + width: 30, + }, + mapMarkerCurrent: { + backgroundColor: '#f97316', + borderColor: '#fed7aa', + borderRadius: 19, + borderWidth: 3, + height: 38, + width: 38, }, mapMarkerText: { color: '#ffffff', @@ -2728,18 +4979,121 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '900', }, - trackingSheet: { - backgroundColor: '#ffffff', - borderTopLeftRadius: 24, - borderTopRightRadius: 24, + trackingCardHeader: { + alignItems: 'flex-start', + flexDirection: 'row', + gap: 14, + justifyContent: 'space-between', + }, + mapPreviewInlineButton: { + alignItems: 'center', + backgroundColor: '#0b57d0', + borderRadius: 16, + flexDirection: 'row', + justifyContent: 'space-between', + minHeight: 58, + paddingHorizontal: 18, + paddingVertical: 12, + }, + mapPreviewInlineButtonTextBlock: { + flex: 1, + gap: 2, + }, + mapPreviewInlineButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '900', + }, + mapPreviewInlineButtonSubtext: { + color: '#dbeafe', + fontSize: 12, + fontWeight: '700', + }, + mapPreviewInlineButtonAction: { + backgroundColor: 'rgba(255, 255, 255, 0.18)', + borderRadius: 999, + marginLeft: 12, + paddingHorizontal: 12, + paddingVertical: 7, + }, + mapPreviewInlineButtonActionText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '900', + }, + photoActionSheetOverlay: { bottom: 0, - gap: 13, + justifyContent: 'flex-end', left: 0, - padding: 18, position: 'absolute', right: 0, + top: 0, + zIndex: 50, + }, + photoActionSheetBackdrop: { + backgroundColor: 'rgba(15, 23, 42, 0.32)', + bottom: 0, + left: 0, + position: 'absolute', + right: 0, + top: 0, + }, + photoActionSheetCard: { + backgroundColor: '#ffffff', + borderColor: '#e5e7eb', + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + borderWidth: 1, + gap: 8, + paddingBottom: 12, + paddingHorizontal: 22, + paddingTop: 10, ...shadow, }, + photoActionSheetTitle: { + color: '#111827', + fontSize: 18, + fontWeight: '800', + textAlign: 'center', + }, + photoActionSheetActions: { + gap: 8, + }, + photoActionSheetAction: { + alignItems: 'center', + borderColor: '#0b57d0', + borderRadius: 14, + borderWidth: 1.2, + height: 44, + justifyContent: 'center', + paddingHorizontal: 16, + paddingVertical: 0, + }, + photoActionSheetActionText: { + color: '#0b57d0', + fontSize: 14, + fontWeight: '800', + includeFontPadding: false, + lineHeight: 18, + }, + photoActionSheetCancel: { + alignItems: 'center', + backgroundColor: '#ffffff', + borderColor: '#dc2626', + borderRadius: 14, + borderWidth: 1.2, + height: 44, + justifyContent: 'center', + paddingHorizontal: 16, + paddingVertical: 0, + }, + photoActionSheetCancelText: { + color: '#dc2626', + fontSize: 14, + fontWeight: '800', + includeFontPadding: false, + lineHeight: 18, + }, sheetHandle: { alignSelf: 'center', backgroundColor: '#c7cdd8', @@ -2817,29 +5171,6 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '800', }, - proofTileRow: { - flexDirection: 'row', - gap: 12, - }, - proofTile: { - alignItems: 'center', - backgroundColor: '#ffffff', - borderColor: '#cbd5e1', - borderRadius: 14, - borderStyle: 'dashed', - borderWidth: 1.4, - flex: 1, - height: 112, - justifyContent: 'center', - padding: 10, - }, - proofTileText: { - color: '#475467', - fontSize: 13, - fontWeight: '800', - lineHeight: 18, - textAlign: 'center', - }, statusBanner: { borderRadius: 14, borderWidth: 1, diff --git a/src/app/NativeRouteMapPreview.tsx b/src/app/NativeRouteMapPreview.tsx new file mode 100644 index 0000000..d88991a --- /dev/null +++ b/src/app/NativeRouteMapPreview.tsx @@ -0,0 +1,215 @@ +import { Camera, GeoJSONSource, Layer, Map, Marker, type CameraRef } from '@maplibre/maplibre-react-native'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import type { AssignedRoute } from '../domain/route/assignedRoute'; +import { buildRouteMapGeoJson, buildRouteProgressFeature } from './routeMapGeoJson'; + +const CAMERA_PADDING = { bottom: 58, left: 42, right: 42, top: 96 } as const; + +type NativeRouteMapPreviewProps = { + currentStepIndex: number; + mapStyleUrl: string; + onUnavailable(): void; + route: AssignedRoute; +}; + +export function NativeRouteMapPreview({ currentStepIndex, mapStyleUrl, onUnavailable, route }: NativeRouteMapPreviewProps) { + const cameraRef = useRef(null); + const mapLoadKey = `${mapStyleUrl}:${route.id}`; + const [mapLoadedState, setMapLoadedState] = useState<{ key: string; loaded: true } | null>(null); + const mapLoaded = mapLoadedState?.key === mapLoadKey; + const model = useMemo(() => buildRouteMapGeoJson(route), [route]); + const currentStopSequence = currentStepIndex <= 0 ? null : route.stops[currentStepIndex - 1]?.sequence ?? null; + const progressFeature = useMemo(() => model === null ? null : buildRouteProgressFeature(model, currentStopSequence), [currentStopSequence, model]); + + useEffect(() => { + if (model !== null) { + return; + } + + onUnavailable(); + }, [model, onUnavailable]); + + useEffect(() => { + if (model === null || !mapLoaded) { + return; + } + + cameraRef.current?.fitBounds(model.bounds, { duration: 0, padding: CAMERA_PADDING }); + }, [mapLoaded, model]); + + if (model === null) { + return null; + } + + return ( + + setMapLoadedState({ key: mapLoadKey, loaded: true })} + preferredFramesPerSecond={30} + scaleBar={false} + style={styles.map} + touchPitch={false} + touchRotate={false} + touchZoom + > + + {model.routeFeature !== null ? ( + + + + + ) : null} + {progressFeature !== null ? ( + + + + ) : null} + + 0 && styles.completedMarkerHalo, currentStepIndex <= 0 && styles.currentMarkerHalo]}> + 0 && styles.completedMarkerDot, currentStepIndex <= 0 && styles.currentMarkerDot]}> + {model.depotFeature.properties.label} + + + + {model.stopCollection.features.map((feature) => { + const isCurrentStop = feature.properties.sequence === currentStopSequence; + const isCompletedStop = currentStopSequence !== null && feature.properties.sequence < currentStopSequence; + + return ( + + + + {feature.properties.label} + + + + ); + })} + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#eaf2f7', + flex: 1, + minHeight: 430, + overflow: 'hidden', + }, + completedMarkerDot: { + backgroundColor: '#475467', + }, + completedMarkerHalo: { + backgroundColor: '#e5e7eb', + }, + currentMarkerDot: { + backgroundColor: '#f97316', + }, + currentMarkerHalo: { + backgroundColor: '#fed7aa', + borderRadius: 9, + height: 17, + width: 17, + }, + depotMarkerDot: { + backgroundColor: '#12b76a', + }, + depotMarkerHalo: { + backgroundColor: '#ecfdf3', + }, + map: { + flex: 1, + }, + markerDot: { + alignItems: 'center', + backgroundColor: '#0b57d0', + borderRadius: 6, + height: 12, + justifyContent: 'center', + width: 12, + }, + markerHalo: { + alignItems: 'center', + backgroundColor: '#ffffff', + borderRadius: 7, + height: 14, + justifyContent: 'center', + width: 14, + }, + markerText: { + color: '#ffffff', + fontSize: 7, + fontWeight: '900', + includeFontPadding: false, + lineHeight: 8, + textAlign: 'center', + textAlignVertical: 'center', + }, +}); diff --git a/src/app/appLayoutMetrics.test.ts b/src/app/appLayoutMetrics.test.ts new file mode 100644 index 0000000..5844161 --- /dev/null +++ b/src/app/appLayoutMetrics.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +import { + BOTTOM_NAV_MIN_HEIGHT, + getBottomChromeOffset, + getBottomChromePadding, + getBottomTabPadding, + getScrollContentBottomPadding, +} from './appLayoutMetrics'; + +describe('app layout metrics', () => { + it('uses the real native bottom inset with a small fallback', () => { + assert.equal(getBottomChromePadding(0), 8); + assert.equal(getBottomChromePadding(47.2), 48); + assert.equal(getBottomChromeOffset(47.2, 58), 106); + assert.equal(getBottomTabPadding(), 8); + }); + + it('keeps scroll content clear of bottom tabs plus native system chrome', () => { + assert.equal(BOTTOM_NAV_MIN_HEIGHT, 62); + assert.equal(getScrollContentBottomPadding(48), 142); + }); + + it('keeps bottom tabs stable instead of following variant-specific safe area insets', () => { + const appSource = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'AppRoot.tsx'), 'utf8'); + + assert.match(appSource, /useSafeAreaInsets\(\)/u); + assert.match(appSource, /getBottomTabPadding\(\)/u); + }); +}); diff --git a/src/app/appLayoutMetrics.ts b/src/app/appLayoutMetrics.ts new file mode 100644 index 0000000..70e6672 --- /dev/null +++ b/src/app/appLayoutMetrics.ts @@ -0,0 +1,20 @@ +export const BOTTOM_NAV_MIN_HEIGHT = 62; +export const BOTTOM_TAB_PADDING = 8; +export const MIN_BOTTOM_CHROME_PADDING = 8; +export const APP_CONTENT_BOTTOM_MARGIN = 24; + +export function getBottomChromePadding(bottomInset: number): number { + return Math.max(MIN_BOTTOM_CHROME_PADDING, Math.ceil(bottomInset)); +} + +export function getBottomChromeOffset(bottomInset: number, offset: number): number { + return getBottomChromePadding(bottomInset) + offset; +} + +export function getBottomTabPadding(): number { + return BOTTOM_TAB_PADDING; +} + +export function getScrollContentBottomPadding(bottomInset: number): number { + return BOTTOM_NAV_MIN_HEIGHT + getBottomTabPadding() + APP_CONTENT_BOTTOM_MARGIN + Math.ceil(bottomInset); +} diff --git a/src/app/authDiagnostics.test.ts b/src/app/authDiagnostics.test.ts new file mode 100644 index 0000000..9ed0889 --- /dev/null +++ b/src/app/authDiagnostics.test.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { buildAuthFailureMessage, buildAuthSuccessMessage } from './authDiagnostics'; +import { createDriverApiHttpError } from '../api/deliveryServer/driverApiError'; +import type { AuthPhase } from './authDiagnostics'; +import type { DriverRuntimeConfig } from './config/driverRuntimeConfig'; + +describe('authDiagnostics', () => { + const liveConfig: DriverRuntimeConfig = { + mode: 'live', + deliveryServerBaseUrl: 'https://clever-route.example', + }; + + it('classifies mock runtime as mock_mode for invite verify', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: { mode: 'mock' }, + phase: 'invite_verify', + error: new Error('kaboom'), + }); + + assert.equal(result.kind, 'mock_mode'); + assert.equal(result.message.includes('mock mode'), true); + }); + + it('classifies missing request from TypeError as request_not_sent', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: liveConfig, + phase: 'route_access', + error: new TypeError('fetch failed'), + }); + + assert.equal(result.kind, 'request_not_sent'); + }); + + it('classifies transport failure as network_failure', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: liveConfig, + phase: 'route_access', + error: new Error('failed to fetch'), + }); + + assert.equal(result.kind, 'network_failure'); + }); + + it('classifies invalid response payload as stale_build', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: liveConfig, + phase: 'invite_verify', + error: new Error('Invalid driver auth response'), + }); + + assert.equal(result.kind, 'stale_build'); + }); + + it('classifies HTTP 400 as server_400', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: liveConfig, + phase: 'invite_verify', + error: createDriverApiHttpError({ endpoint: 'Verify Auth Code', status: 400 }), + }); + + assert.equal(result.kind, 'server_400'); + assert.equal(result.message.includes('400'), true); + }); + + it('classifies HTTP 401 as server_401', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: liveConfig, + phase: 'route_access', + error: createDriverApiHttpError({ endpoint: 'Route Access Lookup', status: 401 }), + }); + + assert.equal(result.kind, 'server_401'); + assert.equal(result.message.includes('401'), true); + }); + + it('explains invalid phone or PIN without exposing which credential failed', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: liveConfig, + phase: 'pin_login', + error: createDriverApiHttpError({ endpoint: 'PIN Login', status: 401 }), + }); + + assert.equal(result.kind, 'server_401'); + assert.equal(result.message, 'PIN login: phone number or PIN is incorrect.'); + }); + + it('explains invalid or expired invite codes during registration', () => { + const result = buildAuthFailureMessage({ + runtimeConfig: liveConfig, + phase: 'invite_verify', + error: createDriverApiHttpError({ endpoint: 'Verify Invite Code', status: 401 }), + }); + + assert.equal(result.kind, 'server_401'); + assert.equal(result.message, 'Account registration: invite code is invalid or expired.'); + }); + + it('builds success message with live endpoint context', () => { + const message = buildAuthSuccessMessage({ runtimeConfig: liveConfig, phase: 'invite_verify' }); + + assert.equal(message.includes('live server https://clever-route.example'), true); + assert.equal(message.includes('Account registration'), true); + }); + + const phaseKinds: AuthPhase[] = ['invite_verify', 'pin_login', 'route_access']; + it('emits non-empty messages for every auth phase', () => { + phaseKinds.forEach((phase) => { + const message = buildAuthSuccessMessage({ runtimeConfig: liveConfig, phase }); + assert.equal(typeof message, 'string'); + assert.ok(message.length > 0); + }); + }); +}); diff --git a/src/app/authDiagnostics.ts b/src/app/authDiagnostics.ts new file mode 100644 index 0000000..0f06b6e --- /dev/null +++ b/src/app/authDiagnostics.ts @@ -0,0 +1,139 @@ +import { DriverApiHttpError } from '../api/deliveryServer/driverApiError'; +import type { DriverRuntimeConfig } from './config/driverRuntimeConfig'; + +export type AuthPhase = 'route_access' | 'invite_verify' | 'pin_login'; + +export type AuthFailureKind = + | 'mock_mode' + | 'request_not_sent' + | 'network_failure' + | 'server_400' + | 'server_401' + | 'stale_build' + | 'server_other_error'; + +export type AuthFailure = { + kind: AuthFailureKind; + message: string; +}; + +export function isStaleClientContractError(error: unknown): boolean { + return ( + error instanceof Error && + ['Invalid route access response', 'Invalid driver auth response'].some((pattern) => error.message.includes(pattern)) + ); +} + +function isRequestNotSentError(error: unknown): boolean { + return ( + error instanceof TypeError && + error.message.toLowerCase().includes('fetch') + ); +} + +function isNetworkError(error: unknown): boolean { + return ( + error instanceof Error && + [ + 'network request failed', + 'failed to fetch', + 'networkerror', + 'network error', + 'econnreset', + ].some((token) => error.message.toLowerCase().includes(token)) + ); +} + +export function getRuntimeHostLabel(runtimeConfig: DriverRuntimeConfig): string { + return runtimeConfig.mode === 'mock' + ? 'mock mode (local services)' + : `live server ${runtimeConfig.deliveryServerBaseUrl}`; +} + +export function buildAuthFailureMessage(input: { + runtimeConfig: DriverRuntimeConfig; + phase: AuthPhase; + error: unknown; +}): AuthFailure { + const phaseLabel = getAuthPhaseLabel(input.phase); + + if (input.runtimeConfig.mode === 'mock') { + return { + kind: 'mock_mode', + message: `${phaseLabel}: running in mock mode; no live request was sent.`, + }; + } + + if (isRequestNotSentError(input.error)) { + return { + kind: 'request_not_sent', + message: `${phaseLabel}: request was not sent. Verify the app bundle and live-server connectivity.`, + }; + } + + if (isNetworkError(input.error)) { + return { + kind: 'network_failure', + message: `${phaseLabel}: network request failed. Retry after checking connectivity.`, + }; + } + + if (isStaleClientContractError(input.error)) { + return { + kind: 'stale_build', + message: `${phaseLabel}: server response shape mismatch; this app build may be stale.`, + }; + } + + if (input.error instanceof DriverApiHttpError) { + if (input.error.status === 401 && input.phase === 'pin_login') { + return { kind: 'server_401', message: 'PIN login: phone number or PIN is incorrect.' }; + } + if (input.error.status === 401 && input.phase === 'invite_verify') { + return { kind: 'server_401', message: 'Account registration: invite code is invalid or expired.' }; + } + return { + kind: input.error.status === 401 ? 'server_401' : input.error.status === 400 ? 'server_400' : 'server_other_error', + message: `${phaseLabel}: ${formatDriverApiStatusMessage(input.error.status)}.`, + }; + } + + return { + kind: 'server_other_error', + message: `${phaseLabel}: unable to complete. Contact dispatch if this continues.`, + }; +} + +export function buildAuthSuccessMessage(input: { + runtimeConfig: DriverRuntimeConfig; + phase: AuthPhase; +}): string { + const phaseLabel = getAuthPhaseLabel(input.phase); + return `${phaseLabel} succeeded via ${getRuntimeHostLabel(input.runtimeConfig)}.`; +} + +function getAuthPhaseLabel(phase: AuthPhase): string { + if (phase === 'invite_verify') { + return 'Account registration'; + } + if (phase === 'pin_login') { + return 'PIN login'; + } + return 'Route access lookup'; +} + +function formatDriverApiStatusMessage(status: DriverApiHttpError['status']): string { + if (status === 400) { + return 'server returned 400 (request payload mismatch)'; + } + + if (status === 401) { + return 'server returned 401 (session expired or unauthorized)'; + } + + if (status === 'unknown') { + return 'server returned unknown HTTP status'; + } + + return `server returned HTTP ${status}`; +} diff --git a/src/app/config/driverRuntimeConfig.test.ts b/src/app/config/driverRuntimeConfig.test.ts index 3055ae2..7499b9e 100644 --- a/src/app/config/driverRuntimeConfig.test.ts +++ b/src/app/config/driverRuntimeConfig.test.ts @@ -9,8 +9,8 @@ describe('driver runtime API config', () => { const services = createDriverRuntimeServices({ config }); const result = await services.routeAccessService.lookupRouteAccess({ + accountAccessToken: 'account-access-token', routeContext: 'route-context', - phoneE164: '+14165550123', }); assert.equal(config.mode, 'mock'); @@ -31,18 +31,63 @@ describe('driver runtime API config', () => { }); const result = await services.routeAccessService.lookupRouteAccess({ + accountAccessToken: 'account-access-token', routeContext: 'route-context', - phoneE164: '+14165550123', }); assert.equal(config.mode, 'live'); assert.deepEqual(result, { status: 'NOT_FOUND' }); assert.deepEqual(requests, [ { - body: { routeContext: 'route-context', phoneE164: '+14165550123' }, + body: { routeContext: 'route-context' }, method: 'POST', url: 'https://delivery.example.com/driver/route-access/lookup', }, ]); }); + + it('uses live driver auth API client when a delivery server base URL is configured', async () => { + const requests: { body: unknown; method: string; url: string }[] = []; + const config = readDriverRuntimeConfig({ + EXPO_PUBLIC_DELIVERY_SERVER_BASE_URL: 'https://delivery.example.com/', + }); + const services = createDriverRuntimeServices({ + config, + fetchImpl: async (url, init) => { + requests.push({ body: JSON.parse(String(init?.body)), method: String(init?.method), url: String(url) }); + return { + ok: true, + status: 200, + json: async () => ({ + data: { + accessToken: 'access-token', + expiresAt: '2026-05-15T00:15:00.000Z', + refreshToken: 'refresh-token', + refreshTokenExpiresAt: '2026-06-15T00:00:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', + }, + error: null, + }), + }; + }, + }); + + const result = await services.driverAuthService.register({ + inviteCode: 'ABC123', + phoneE164: '+14165550123', + pin: '654321', + }); + + assert.equal(result.accountAccess.accessToken, 'access-token'); + assert.equal(result.accountAccess.refreshToken, 'refresh-token'); + assert.deepEqual(requests, [ + { + body: { phone: '+14165550123', inviteCode: 'ABC123', pin: '654321' }, + method: 'POST', + url: 'https://delivery.example.com/driver/auth/verify-invite', + }, + ]); + }); }); diff --git a/src/app/config/driverRuntimeConfig.ts b/src/app/config/driverRuntimeConfig.ts index c682486..9ac713e 100644 --- a/src/app/config/driverRuntimeConfig.ts +++ b/src/app/config/driverRuntimeConfig.ts @@ -1,4 +1,5 @@ import { createMockRouteAccessService, createRouteAccessApiClient, type FetchLike, type RouteAccessService } from '../../domain/routeAccess/routeAccess'; +import { createDriverAuthApiClient, createMockDriverAuthService, type DriverAuthService } from '../../domain/driverAuth/driverAuth'; export type DriverRuntimeConfig = | { @@ -10,6 +11,7 @@ export type DriverRuntimeConfig = }; export type DriverRuntimeServices = { + driverAuthService: DriverAuthService; routeAccessService: RouteAccessService; }; @@ -31,11 +33,16 @@ export function createDriverRuntimeServices(input: { }): DriverRuntimeServices { if (input.config.mode === 'mock') { return { + driverAuthService: createMockDriverAuthService(), routeAccessService: createMockRouteAccessService(), }; } return { + driverAuthService: createDriverAuthApiClient({ + baseUrl: input.config.deliveryServerBaseUrl, + fetchImpl: input.fetchImpl, + }), routeAccessService: createRouteAccessApiClient({ baseUrl: input.config.deliveryServerBaseUrl, fetchImpl: input.fetchImpl, diff --git a/src/app/driverMainTabs.test.ts b/src/app/driverMainTabs.test.ts new file mode 100644 index 0000000..72b706f --- /dev/null +++ b/src/app/driverMainTabs.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + DRIVER_APP_SERVER_BACKED_FEATURE_GATES, + getDriverMainTabs, + getDriverPlaceholderCopy, + getDriverRouteStatusForTabs, + getVisibleBottomTab, + shouldShowDriverBottomTabs, +} from './driverMainTabs'; + +describe('driver main tab IA', () => { + it('defines the bottom tabs in the approved app-level order with accessibility labels', () => { + assert.deepEqual( + getDriverMainTabs().map((tab) => ({ id: tab.id, label: tab.label, accessibilityLabel: tab.accessibilityLabel })), + [ + { id: 'home', label: 'Home', accessibilityLabel: 'Home tab' }, + { id: 'routes', label: 'Routes', accessibilityLabel: 'Routes tab' }, + { id: 'earnings', label: 'Earnings', accessibilityLabel: 'Earnings tab' }, + { id: 'profile', label: 'Profile', accessibilityLabel: 'Profile tab' }, + ], + ); + }); + + it('excludes auth and dedicated preview screens from the bottom tab shell', () => { + assert.equal(shouldShowDriverBottomTabs('loginPhone'), false); + assert.equal(shouldShowDriverBottomTabs('loginDetail'), false); + assert.equal(shouldShowDriverBottomTabs('arrivalCheck'), false); + assert.equal(shouldShowDriverBottomTabs('liveMapPreview'), false); + assert.equal(shouldShowDriverBottomTabs('proofCamera'), false); + assert.equal(shouldShowDriverBottomTabs('routePreview'), false); + assert.equal(shouldShowDriverBottomTabs('mainTabs'), true); + assert.equal(shouldShowDriverBottomTabs('routeSession'), true); + assert.equal(shouldShowDriverBottomTabs('liveTracking'), true); + }); + + it('keeps active route subflow visually owned by Home', () => { + assert.equal(getVisibleBottomTab({ screen: 'mainTabs', selectedMainTab: 'routes' }), 'routes'); + assert.equal(getVisibleBottomTab({ screen: 'routeSession', selectedMainTab: 'routes' }), 'home'); + assert.equal(getVisibleBottomTab({ screen: 'routePreview', selectedMainTab: 'routes' }), 'home'); + assert.equal(getVisibleBottomTab({ screen: 'completedDeliveries', selectedMainTab: 'profile' }), 'home'); + }); + + it('classifies route filter status deterministically for the selected current-session route only', () => { + assert.equal(getDriverRouteStatusForTabs({ routeId: 'route-1', selectedRouteId: 'route-1', selectedRouteStatus: 'active' }), 'active'); + assert.equal(getDriverRouteStatusForTabs({ routeId: 'route-1', selectedRouteId: 'route-1', selectedRouteStatus: 'completed' }), 'completed'); + assert.equal(getDriverRouteStatusForTabs({ routeId: 'route-2', selectedRouteId: 'route-1', selectedRouteStatus: 'completed' }), 'upcoming'); + }); + + it('keeps server-backed history profile earnings and deletion disabled for this phase', () => { + assert.deepEqual(DRIVER_APP_SERVER_BACKED_FEATURE_GATES, { + accountDeletion: false, + earnings: false, + profileUpdate: false, + routeHistory: false, + }); + assert.match(getDriverPlaceholderCopy('routeHistory'), /Past route history is not available/u); + assert.match(getDriverPlaceholderCopy('earnings'), /coming soon/u); + assert.match(getDriverPlaceholderCopy('profileUpdate'), /future API/u); + assert.match(getDriverPlaceholderCopy('accountDeletion'), /not available/u); + }); +}); diff --git a/src/app/driverMainTabs.ts b/src/app/driverMainTabs.ts new file mode 100644 index 0000000..76e498c --- /dev/null +++ b/src/app/driverMainTabs.ts @@ -0,0 +1,75 @@ +export const DRIVER_MAIN_TAB_IDS = ['home', 'routes', 'earnings', 'profile'] as const; + +export type DriverMainTabId = typeof DRIVER_MAIN_TAB_IDS[number]; + +export type DriverMainTab = { + accessibilityLabel: string; + id: DriverMainTabId; + label: 'Earnings' | 'Home' | 'Profile' | 'Routes'; +}; + +export const DRIVER_MAIN_TABS: DriverMainTab[] = [ + { id: 'home', label: 'Home', accessibilityLabel: 'Home tab' }, + { id: 'routes', label: 'Routes', accessibilityLabel: 'Routes tab' }, + { id: 'earnings', label: 'Earnings', accessibilityLabel: 'Earnings tab' }, + { id: 'profile', label: 'Profile', accessibilityLabel: 'Profile tab' }, +]; + +export const DRIVER_APP_SERVER_BACKED_FEATURE_GATES = { + accountDeletion: false, + earnings: false, + profileUpdate: false, + routeHistory: false, +} as const; + +export type DriverAppScreenId = + | 'arrivalCheck' + | 'completedDeliveries' + | 'liveTracking' + | 'liveMapPreview' + | 'loginDetail' + | 'loginPhone' + | 'mainTabs' + | 'proofCamera' + | 'routePreview' + | 'routeSession' + | 'stopCompleted' + | 'stopDetails'; + +export type DriverRouteStatusForTabs = 'active' | 'completed' | 'unfinished' | 'upcoming'; + +export function getDriverMainTabs(): DriverMainTab[] { + return [...DRIVER_MAIN_TABS]; +} + +export function shouldShowDriverBottomTabs(screen: DriverAppScreenId): boolean { + return screen !== 'arrivalCheck' && screen !== 'loginPhone' && screen !== 'loginDetail' && screen !== 'liveMapPreview' && screen !== 'proofCamera' && screen !== 'routePreview'; +} + +export function getVisibleBottomTab(input: { + screen: DriverAppScreenId; + selectedMainTab: DriverMainTabId; +}): DriverMainTabId { + return input.screen === 'mainTabs' ? input.selectedMainTab : 'home'; +} + +export function getDriverRouteStatusForTabs(input: { + routeId: string; + selectedRouteId: string | null; + selectedRouteStatus: DriverRouteStatusForTabs; +}): DriverRouteStatusForTabs { + return input.routeId === input.selectedRouteId ? input.selectedRouteStatus : 'upcoming'; +} + +export function getDriverPlaceholderCopy(feature: keyof typeof DRIVER_APP_SERVER_BACKED_FEATURE_GATES): string { + switch (feature) { + case 'accountDeletion': + return 'Account deletion is not available in the app yet. Contact dispatch support to request account changes.'; + case 'earnings': + return 'Earnings are coming soon. Payout rules and history are not connected to this beta yet.'; + case 'profileUpdate': + return 'Profile editing is local-only in this beta. Server profile updates require a future API.'; + case 'routeHistory': + return 'Past route history is not available in this beta. Completed shows only this session until a route-history API exists.'; + } +} diff --git a/src/app/loginFlowBehavior.test.ts b/src/app/loginFlowBehavior.test.ts new file mode 100644 index 0000000..44a8451 --- /dev/null +++ b/src/app/loginFlowBehavior.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const appRootPath = join(dirname(fileURLToPath(import.meta.url)), 'AppRoot.tsx'); + +describe('driver login flow', () => { + it('uses phone then PIN login, with invite code only in first-registration mode', () => { + const source = readFileSync(appRootPath, 'utf8'); + const loginDetailScreen = source.slice( + source.indexOf('function LoginDetailScreen'), + source.indexOf('function HomePage'), + ); + + assert.match(loginDetailScreen, /label="6-digit PIN"/u); + assert.match(loginDetailScreen, /isRegistration/u); + assert.match(loginDetailScreen, /label="Invite Code"/u); + assert.match(loginDetailScreen, /label="Confirm PIN"/u); + assert.match(source, /driverAuthService\.login/u); + assert.match(source, /driverAuthService\.register/u); + assert.doesNotMatch(source, /saveVerifiedDriver/u); + assert.doesNotMatch(source, /displayName:/u); + assert.doesNotMatch(loginDetailScreen, /label="First Name"/u); + assert.doesNotMatch(loginDetailScreen, /label="Last Name"/u); + assert.doesNotMatch(source, /Enter your first and last name/u); + }); +}); diff --git a/src/app/routeListBehavior.test.ts b/src/app/routeListBehavior.test.ts new file mode 100644 index 0000000..cf5626a --- /dev/null +++ b/src/app/routeListBehavior.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const appRootPath = join(dirname(fileURLToPath(import.meta.url)), 'AppRoot.tsx'); + +function getRoutesPageSource(): string { + const source = readFileSync(appRootPath, 'utf8'); + const start = source.indexOf('function RoutesPage('); + const end = source.indexOf('function EarningsPage(', start); + + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + return source.slice(start, end); +} + +describe('routes list behavior', () => { + it('lets the selected route card collapse to title date and status only', () => { + const source = getRoutesPageSource(); + + assert.match(source, /const \[collapsedRouteKey, setCollapsedRouteKey\] = useState\(null\)/u); + assert.match(source, /const isRouteCardExpanded = activeRouteCollapseKey === null \|\| collapsedRouteKey !== activeRouteCollapseKey/u); + assert.match(source, /setCollapsedRouteKey\(\(value\) => value === activeRouteCollapseKey \? null : activeRouteCollapseKey\)/u); + assert.match(source, /\{activeSession\.route\.name\}<\/Text>/u); + assert.match(source, /\{activeSession\.route\.deliveryDate\}<\/Text>/u); + assert.match(source, /label=\{formatRouteStatus\(activeRouteStatusForTabs \?\? 'upcoming'\)\}/u); + assert.doesNotMatch(source, / { + const source = readFileSync(appRootPath, 'utf8'); + const openMainTabSource = source.slice( + source.indexOf('function openMainTab'), + source.indexOf('function openHomeRoot'), + ); + const refreshRoutesSource = source.slice( + source.indexOf('const handleRefreshRoutes'), + source.indexOf('useEffect(() => {', source.indexOf('const handleRefreshRoutes')), + ); + const noAssignedRouteSource = source.slice( + source.indexOf('const openVerifiedNoAssignedRoute'), + source.indexOf('const handleLoginAndLoadRoutes'), + ); + const appStateRefreshSource = source.slice( + source.indexOf("AppState.addEventListener('change'"), + source.indexOf('return () => subscription.remove()', source.indexOf("AppState.addEventListener('change'")), + ); + + assert.match(openMainTabSource, /tab === 'routes'/u); + assert.match(openMainTabSource, /void handleRefreshRoutes\(\)/u); + assert.match(refreshRoutesSource, /allowVerifiedDriverNoRoute: true/u); + assert.match(noAssignedRouteSource, /resetRouteProgress\(\)/u); + assert.match(noAssignedRouteSource, /clearCachedRouteAccess\(\)/u); + assert.match(appStateRefreshSource, /screen === 'mainTabs'/u); + assert.doesNotMatch(appStateRefreshSource, /selectedMainTab === 'routes'/u); + }); +}); diff --git a/src/app/routeMapGeoJson.test.ts b/src/app/routeMapGeoJson.test.ts new file mode 100644 index 0000000..44922f5 --- /dev/null +++ b/src/app/routeMapGeoJson.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { sampleAssignedRoute } from '../domain/route/assignedRoute'; +import { buildRouteMapGeoJson, buildRouteProgressFeature, DEFAULT_DRIVER_MAP_STYLE_URL, readDriverMapStyleUrl } from './routeMapGeoJson'; + +describe('route map geojson model', () => { + it('builds ordered stop points and fitted bounds from an assigned route', () => { + const model = buildRouteMapGeoJson(sampleAssignedRoute); + + assert.notEqual(model, null); + assert.deepEqual(model?.depotFeature.geometry.coordinates, sampleAssignedRoute.routeGeometry?.coordinates[0]); + assert.equal(model?.depotFeature.properties.label, 'D'); + assert.deepEqual(model?.stopCollection.features.map((feature) => feature.properties.label), ['1', '2']); + assert.equal(model?.bounds.length, 4); + assert.ok(model !== null && model.bounds[0] < model.bounds[2]); + assert.ok(model !== null && model.bounds[1] < model.bounds[3]); + }); + + it('does not draw a route line when geometry only connects depot and stops directly', () => { + const model = buildRouteMapGeoJson(sampleAssignedRoute); + + assert.equal(model?.routeFeature, null); + }); + + it('draws the route line when the server sends road-following geometry vertices', () => { + const model = buildRouteMapGeoJson({ + ...sampleAssignedRoute, + routeGeometry: { + coordinates: [ + [-79.3832, 43.6532], + [-79.3828, 43.652], + [-79.3821, 43.6505], + [-79.3818, 43.6488], + [-79.386, 43.6498], + [-79.391, 43.651], + ], + type: 'LineString', + }, + }); + + assert.equal(model?.routeFeature?.geometry.type, 'LineString'); + assert.equal(model?.routeFeature?.geometry.coordinates.length, 6); + }); + + it('builds a progress line from depot to the current stop', () => { + const model = buildRouteMapGeoJson({ + ...sampleAssignedRoute, + routeGeometry: { + coordinates: [ + [-79.3832, 43.6532], + [-79.3828, 43.652], + [-79.3821, 43.6505], + [-79.3818, 43.6488], + [-79.386, 43.6498], + [-79.391, 43.651], + ], + type: 'LineString', + }, + }); + + assert.notEqual(model, null); + const progressFeature = model === null ? null : buildRouteProgressFeature(model, 1); + + assert.equal(progressFeature?.properties.kind, 'route_progress'); + assert.deepEqual(progressFeature?.geometry.coordinates, [ + [-79.3832, 43.6532], + [-79.3828, 43.652], + [-79.3821, 43.6505], + [-79.3818, 43.6488], + ]); + }); + + it('removes repeated route vertices before sending the line to MapLibre', () => { + const model = buildRouteMapGeoJson({ + ...sampleAssignedRoute, + routeGeometry: { + coordinates: [ + [-79.3832, 43.6532], + [-79.3832, 43.6532], + [-79.3828, 43.652], + [-79.3828, 43.652], + [-79.3821, 43.6505], + [-79.3818, 43.6488], + ], + type: 'LineString', + }, + }); + + assert.deepEqual(model?.routeFeature?.geometry.coordinates, [ + [-79.3832, 43.6532], + [-79.3828, 43.652], + [-79.3821, 43.6505], + [-79.3818, 43.6488], + ]); + }); + + it('falls back to the public OpenFreeMap style when no app-specific style is configured', () => { + assert.equal(readDriverMapStyleUrl(undefined), DEFAULT_DRIVER_MAP_STYLE_URL); + assert.equal(readDriverMapStyleUrl(' https://maps.example.com/style.json '), 'https://maps.example.com/style.json'); + }); + + it('does not render an interactive map when route geometry is missing', () => { + assert.equal(buildRouteMapGeoJson({ ...sampleAssignedRoute, routeGeometry: null }), null); + }); +}); diff --git a/src/app/routeMapGeoJson.ts b/src/app/routeMapGeoJson.ts new file mode 100644 index 0000000..d9f04b5 --- /dev/null +++ b/src/app/routeMapGeoJson.ts @@ -0,0 +1,246 @@ +import type { Feature, FeatureCollection, LineString, Point } from 'geojson'; + +import type { AssignedRoute, AssignedRouteLngLat } from '../domain/route/assignedRoute'; + +export const DEFAULT_DRIVER_MAP_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty'; + +export type RouteDepotFeature = Feature; +export type RouteLineFeature = Feature; +export type RouteProgressLineFeature = Feature; +export type RouteStopFeature = Feature; +export type RouteStopFeatureCollection = FeatureCollection; + +export type RouteMapGeoJsonModel = { + bounds: [west: number, south: number, east: number, north: number]; + depotFeature: RouteDepotFeature; + routeFeature: RouteLineFeature | null; + stopCollection: RouteStopFeatureCollection; +}; + +export function readDriverMapStyleUrl(value: string | null | undefined): string { + const trimmed = value?.trim(); + if (trimmed === undefined || trimmed === '') { + return DEFAULT_DRIVER_MAP_STYLE_URL; + } + + return trimmed; +} + +export function buildRouteMapGeoJson(route: AssignedRoute): RouteMapGeoJsonModel | null { + const routeCoordinates = normalizeRouteCoordinates(route.routeGeometry?.coordinates ?? []); + if (routeCoordinates.length < 2) { + return null; + } + + const stopCoordinatesById = new Map(); + for (const point of route.routeStopPoints) { + const coordinates = point.snappedCoordinates ?? point.inputCoordinates; + if (isRenderableLngLat(coordinates)) { + stopCoordinatesById.set(point.deliveryStopId, coordinates); + } + } + + const stopFeatures: RouteStopFeature[] = route.stops + .map((stop): RouteStopFeature | null => { + const coordinates = stopCoordinatesById.get(stop.deliveryStopId) ?? readStopLngLat(stop.coordinates); + if (!isRenderableLngLat(coordinates)) { + return null; + } + + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates, + }, + properties: { + kind: 'stop', + label: String(stop.sequence), + sequence: stop.sequence, + }, + }; + }) + .filter((feature): feature is RouteStopFeature => feature !== null) + .sort((left, right) => left.properties.sequence - right.properties.sequence); + + const depotCoordinates = routeCoordinates[0]; + const depotFeature: RouteDepotFeature = { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: depotCoordinates, + }, + properties: { + kind: 'depot', + label: 'D', + sequence: 0, + }, + }; + + const bounds = calculateBounds([ + ...routeCoordinates, + depotCoordinates, + ...stopFeatures.map((feature) => feature.geometry.coordinates as AssignedRouteLngLat), + ]); + if (bounds === null) { + return null; + } + + return { + bounds, + depotFeature, + routeFeature: hasRoadFollowingGeometry(routeCoordinates, stopFeatures.length) + ? { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: routeCoordinates, + }, + properties: { + kind: 'route', + }, + } + : null, + stopCollection: { + type: 'FeatureCollection', + features: stopFeatures, + }, + }; +} + +export function buildRouteProgressFeature(model: RouteMapGeoJsonModel, targetStopSequence: number | null): RouteProgressLineFeature | null { + if (model.routeFeature === null || targetStopSequence === null) { + return null; + } + + const targetStop = model.stopCollection.features.find((feature) => feature.properties.sequence === targetStopSequence) ?? null; + if (targetStop === null) { + return null; + } + + const routeCoordinates = model.routeFeature.geometry.coordinates as AssignedRouteLngLat[]; + const targetCoordinate = targetStop.geometry.coordinates as AssignedRouteLngLat; + const targetRouteIndex = findNearestRouteCoordinateIndex(routeCoordinates, targetCoordinate); + if (targetRouteIndex < 1) { + return null; + } + + const progressCoordinates = routeCoordinates.slice(0, targetRouteIndex + 1); + const lastProgressCoordinate = progressCoordinates[progressCoordinates.length - 1]; + if (lastProgressCoordinate !== undefined && !sameLngLat(lastProgressCoordinate, targetCoordinate)) { + progressCoordinates.push(targetCoordinate); + } + + return { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: progressCoordinates, + }, + properties: { + kind: 'route_progress', + }, + }; +} + +function hasRoadFollowingGeometry(routeCoordinates: AssignedRouteLngLat[], stopCount: number): boolean { + return routeCoordinates.length > stopCount + 1; +} + +function normalizeRouteCoordinates(coordinates: unknown[]): AssignedRouteLngLat[] { + const normalized: AssignedRouteLngLat[] = []; + for (const coordinate of coordinates) { + if (!isRenderableLngLat(coordinate)) { + continue; + } + + const previous = normalized[normalized.length - 1]; + if (previous !== undefined && previous[0] === coordinate[0] && previous[1] === coordinate[1]) { + continue; + } + + normalized.push(coordinate); + } + + return normalized; +} + +function readStopLngLat(coordinates: AssignedRoute['stops'][number]['coordinates']): AssignedRouteLngLat | null { + if (coordinates === null) { + return null; + } + + return [coordinates.longitude, coordinates.latitude]; +} + +function calculateBounds(coordinates: AssignedRouteLngLat[]): RouteMapGeoJsonModel['bounds'] | null { + const renderableCoordinates = coordinates.filter(isRenderableLngLat); + if (renderableCoordinates.length < 2) { + return null; + } + + let west = Number.POSITIVE_INFINITY; + let east = Number.NEGATIVE_INFINITY; + let south = Number.POSITIVE_INFINITY; + let north = Number.NEGATIVE_INFINITY; + for (const [longitude, latitude] of renderableCoordinates) { + west = Math.min(west, longitude); + east = Math.max(east, longitude); + south = Math.min(south, latitude); + north = Math.max(north, latitude); + } + + if (!Number.isFinite(west) || !Number.isFinite(east) || !Number.isFinite(south) || !Number.isFinite(north)) { + return null; + } + + const minSpan = 0.002; + if (east - west < minSpan) { + const center = (east + west) / 2; + west = center - minSpan / 2; + east = center + minSpan / 2; + } + if (north - south < minSpan) { + const center = (north + south) / 2; + south = center - minSpan / 2; + north = center + minSpan / 2; + } + + return [west, south, east, north]; +} + +function findNearestRouteCoordinateIndex(routeCoordinates: AssignedRouteLngLat[], targetCoordinate: AssignedRouteLngLat): number { + let nearestIndex = -1; + let nearestDistance = Number.POSITIVE_INFINITY; + + routeCoordinates.forEach((coordinate, index) => { + const longitudeDelta = coordinate[0] - targetCoordinate[0]; + const latitudeDelta = coordinate[1] - targetCoordinate[1]; + const distance = longitudeDelta * longitudeDelta + latitudeDelta * latitudeDelta; + if (distance < nearestDistance) { + nearestDistance = distance; + nearestIndex = index; + } + }); + + return nearestIndex; +} + +function sameLngLat(left: AssignedRouteLngLat, right: AssignedRouteLngLat): boolean { + return left[0] === right[0] && left[1] === right[1]; +} + +function isRenderableLngLat(value: unknown): value is AssignedRouteLngLat { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === 'number' && + typeof value[1] === 'number' && + Number.isFinite(value[0]) && + Number.isFinite(value[1]) && + value[0] >= -180 && + value[0] <= 180 && + value[1] >= -90 && + value[1] <= 90 + ); +} diff --git a/src/app/routePreviewBehavior.test.ts b/src/app/routePreviewBehavior.test.ts new file mode 100644 index 0000000..71f6ba2 --- /dev/null +++ b/src/app/routePreviewBehavior.test.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; + +import { + buildRoutePreviewSequence, + buildRoutePreviewRegionItems, + ROUTE_PREVIEW_ALLOWED_ACTIONS, + ROUTE_PREVIEW_COPY, + ROUTE_PREVIEW_PROHIBITED_ACTION_LABELS, + ROUTE_PREVIEW_REQUIRED_FIELDS, +} from './routePreviewBehavior'; +import { sampleAssignedRoute } from '../domain/route/assignedRoute'; + +const appRootPath = join(dirname(fileURLToPath(import.meta.url)), 'AppRoot.tsx'); + +function getRoutePreviewComponentSource(): string { + const source = readFileSync(appRootPath, 'utf8'); + const start = source.indexOf('function RoutePreviewScreen('); + const end = source.indexOf('function RouteSessionScreen(', start); + + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + return source.slice(start, end); +} + +describe('route preview behavior', () => { + it('defines the approved compact read-only preview fields and actions', () => { + assert.deepEqual(ROUTE_PREVIEW_REQUIRED_FIELDS, [ + 'Date', + 'Map', + 'Region', + 'Stops', + 'Distance', + 'Time', + 'Sequence', + ]); + assert.deepEqual(ROUTE_PREVIEW_ALLOWED_ACTIONS, [ + 'Back', + ROUTE_PREVIEW_COPY.mapAccessibilityLabel, + ]); + }); + + + it('formats Region as compact list items instead of one long string', () => { + assert.deepEqual(buildRoutePreviewRegionItems(sampleAssignedRoute), ['Toronto, ON']); + + const mixedAreaRoute = { + ...sampleAssignedRoute, + stops: sampleAssignedRoute.stops.map((stop, index) => ({ + ...stop, + address: { + ...stop.address, + city: index === 0 ? 'Toronto' : 'Mississauga', + province: 'ON', + }, + })), + }; + + assert.deepEqual(buildRoutePreviewRegionItems(mixedAreaRoute), ['Toronto, ON', 'Mississauga, ON']); + }); + + it('formats Sequence as a compact ordered address list, not numeric path markers only', () => { + const previewSequence = buildRoutePreviewSequence(sampleAssignedRoute, 1); + + assert.deepEqual(previewSequence, { + items: [ + { + address: '100 King St W', + deliveryStopId: sampleAssignedRoute.stops[0]!.deliveryStopId, + marker: '1', + }, + ], + overflowCount: 1, + }); + }); + + it('keeps operational session controls out of the Route Details preview component', () => { + const componentSource = getRoutePreviewComponentSource(); + + assert.match(componentSource, /ROUTE_PREVIEW_COPY\.title/u); + assert.match(componentSource, /ROUTE_PREVIEW_LABELS\.date/u); + assert.match(componentSource, /ROUTE_PREVIEW_LABELS\.map/u); + assert.match(componentSource, /RoutePreviewRegionBlock/u); + assert.match(componentSource, /buildRoutePreviewRegionItems/u); + assert.match(readFileSync(appRootPath, 'utf8'), /function RoutePreviewRegionBlock[\s\S]*ROUTE_PREVIEW_LABELS\.region[\s\S]*items\.map/u); + assert.match(componentSource, /ROUTE_PREVIEW_LABELS\.stops/u); + assert.match(componentSource, /ROUTE_PREVIEW_LABELS\.distance/u); + assert.match(componentSource, /ROUTE_PREVIEW_LABELS\.time/u); + assert.match(componentSource, /ROUTE_PREVIEW_LABELS\.sequence/u); + assert.doesNotMatch(componentSource, /DataRow label=\{ROUTE_PREVIEW_LABELS\.region\}/u); + + assert.match(componentSource, /hideRightAction/u); + assert.doesNotMatch(componentSource, /Menu/u); + assert.doesNotMatch(componentSource, /PrimaryButton/u); + assert.doesNotMatch(componentSource, /SecondaryButton/u); + assert.doesNotMatch(componentSource, /onStartRoute/u); + assert.doesNotMatch(componentSource, /onOpenNavigation/u); + assert.doesNotMatch(componentSource, /onOpenStop/u); + assert.doesNotMatch(componentSource, /onViewCurrentStop/u); + + for (const label of ROUTE_PREVIEW_PROHIBITED_ACTION_LABELS) { + assert.doesNotMatch(componentSource, new RegExp(label, 'u')); + } + }); + + it('routes Route Details and Continue Session to distinct app handlers', () => { + const source = readFileSync(appRootPath, 'utf8'); + + assert.match(source, /onOpenRoutePreview=\{handleOpenRoutePreview\}/u); + assert.match(source, /onContinueRoute=\{handleOpenRouteSession\}/u); + assert.match(source, /onOpenMapPreview=\{\(\) => openMapPreviewFrom\('routePreview'\)\}/u); + assert.match(source, /onBack=\{\(\) => setScreen\(mapPreviewBackTarget\)\}/u); + }); +}); diff --git a/src/app/routePreviewBehavior.ts b/src/app/routePreviewBehavior.ts new file mode 100644 index 0000000..0b3e712 --- /dev/null +++ b/src/app/routePreviewBehavior.ts @@ -0,0 +1,103 @@ +import type { AssignedRoute, AssignedRouteStop } from '../domain/route/assignedRoute'; + +export const ROUTE_PREVIEW_LABELS = { + date: 'Date', + distance: 'Distance', + map: 'Map', + region: 'Region', + sequence: 'Sequence', + stops: 'Stops', + time: 'Time', +} as const; + +export const ROUTE_PREVIEW_COPY = { + mapAccessibilityLabel: 'Open large route map preview', + title: 'Route Details', +} as const; + +export const ROUTE_PREVIEW_ALLOWED_ACTIONS = [ + 'Back', + 'Open large route map preview', +] as const; + +export const ROUTE_PREVIEW_PROHIBITED_ACTION_LABELS = [ + 'Start Session', + 'Continue Session', + 'Open in Map', + 'Arrived', + 'Find Next Stop', + 'Finish Route', + 'View Stop Details', + 'Map Preview', + 'Photo Proof', + 'Complete Stop', + 'Menu', +] as const; + +export const ROUTE_PREVIEW_REQUIRED_FIELDS = [ + ROUTE_PREVIEW_LABELS.date, + ROUTE_PREVIEW_LABELS.map, + ROUTE_PREVIEW_LABELS.region, + ROUTE_PREVIEW_LABELS.stops, + ROUTE_PREVIEW_LABELS.distance, + ROUTE_PREVIEW_LABELS.time, + ROUTE_PREVIEW_LABELS.sequence, +] as const; + +export const ROUTE_PREVIEW_SEQUENCE_LIMIT = 6; + +export type RoutePreviewSequenceItem = { + address: string; + deliveryStopId: string; + marker: string; +}; + +export type RoutePreviewSequence = { + items: RoutePreviewSequenceItem[]; + overflowCount: number; +}; + +export function buildRoutePreviewRegionItems(route: Pick): string[] { + const areas = uniqueNonEmpty( + route.stops.map((stop) => formatRoutePreviewArea(stop)), + ); + + return areas.length === 0 ? [route.timezone] : areas; +} + +export function buildRoutePreviewSequence( + route: Pick, + limit = ROUTE_PREVIEW_SEQUENCE_LIMIT, +): RoutePreviewSequence { + const safeLimit = Math.max(0, Math.floor(limit)); + const visibleStops = route.stops.slice(0, safeLimit); + + return { + items: visibleStops.map((stop) => ({ + address: formatRoutePreviewStopAddress(stop), + deliveryStopId: stop.deliveryStopId, + marker: String(stop.sequence), + })), + overflowCount: Math.max(route.stops.length - visibleStops.length, 0), + }; +} + +function formatRoutePreviewArea(stop: AssignedRouteStop): string { + return [stop.address.city, stop.address.province] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(', '); +} + +function formatRoutePreviewStopAddress(stop: AssignedRouteStop): string { + const street = [stop.address.address1, stop.address.address2] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(', '); + + return street || `Stop ${stop.sequence}`; +} + +function uniqueNonEmpty(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} diff --git a/src/app/routeSessionBehavior.test.ts b/src/app/routeSessionBehavior.test.ts new file mode 100644 index 0000000..3f55964 --- /dev/null +++ b/src/app/routeSessionBehavior.test.ts @@ -0,0 +1,224 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const appRootPath = join(dirname(fileURLToPath(import.meta.url)), 'AppRoot.tsx'); +const cameraCapturePath = join(dirname(fileURLToPath(import.meta.url)), '../platform/expo/camera/expoProofPhotoCaptureService.ts'); +const nativeMapPath = join(dirname(fileURLToPath(import.meta.url)), 'NativeRouteMapPreview.tsx'); + +function getRouteSessionComponentSource(): string { + const source = readFileSync(appRootPath, 'utf8'); + const start = source.indexOf('function RouteSessionScreen('); + const end = source.indexOf('function LiveTrackingScreen(', start); + + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + return source.slice(start, end); +} + +describe('route session current task behavior', () => { + it('puts task-specific primary actions inside Current Task instead of leaving only generic route buttons', () => { + const componentSource = getRouteSessionComponentSource(); + + assert.match(componentSource, /label: 'Complete Pickup'/u); + assert.match(componentSource, /label: 'Mark as Arrived'/u); + assert.match(componentSource, /showPrimaryActionInCurrentTask/u); + assert.match(componentSource, /styles\.currentTaskActions/u); + assert.match(componentSource, /styles\.currentTaskAddressText/u); + assert.match(componentSource, / { + const appSource = readFileSync(appRootPath, 'utf8'); + + assert.match(appSource, /style=\{\[styles\.timelineTitle, state === 'current' && styles\.timelineTitleCurrent\]\}/u); + assert.match(appSource, /timelineTitle:[\s\S]*fontWeight: '400'/u); + assert.match(appSource, /timelineTitleCurrent:[\s\S]*fontWeight: '700'/u); + }); + + it('shows only basic stop addresses in Route Sequence stop rows', () => { + const componentSource = getRouteSessionComponentSource(); + + assert.match(componentSource, /title=\{formatStopStreetAddress\(stop\)\}/u); + assert.doesNotMatch(componentSource, /formatRouteSequenceStopSubtitle/u); + assert.doesNotMatch(componentSource, /subtitle=\{formatRouteSequenceStopSubtitle\(stop\)\}/u); + }); + + it('uses the route name instead of company domain as the route session card title', () => { + const componentSource = getRouteSessionComponentSource(); + + assert.match(componentSource, /\{route\.name\}<\/Text>/u); + assert.doesNotMatch(componentSource, /company\?\.companyDisplayName \?\? route\.name/u); + assert.doesNotMatch(componentSource, /route\.shopDomain/u); + }); + + it('passes current step context into map previews for current destination highlighting', () => { + const appSource = readFileSync(appRootPath, 'utf8'); + const nativeMapSource = readFileSync(nativeMapPath, 'utf8'); + + assert.match(appSource, /currentStepIndex=\{currentStepIndex\}/u); + assert.match(nativeMapSource, /currentStopSequence/u); + assert.match(nativeMapSource, /currentMarkerHalo/u); + assert.doesNotMatch(nativeMapSource, /Current: Stop/u); + }); + + it('uses numbered marker overlays while keeping the route line server-geometry-only', () => { + const nativeMapSource = readFileSync(nativeMapPath, 'utf8'); + + assert.match(nativeMapSource, //u); + assert.match(nativeMapSource, /model\.routeFeature !== null/u); + assert.doesNotMatch(nativeMapSource, /route-preview-stop-label/u); + }); + + it('mutes completed markers and the route segment to the current stop', () => { + const nativeMapSource = readFileSync(nativeMapPath, 'utf8'); + + assert.match(nativeMapSource, /buildRouteProgressFeature\(model, currentStopSequence\)/u); + assert.match(nativeMapSource, /id="route-preview-progress-line"/u); + assert.match(nativeMapSource, /'line-color': '#f97316'/u); + assert.match(nativeMapSource, /currentStepIndex > 0 && styles\.completedMarkerDot/u); + assert.match(nativeMapSource, /isCompletedStop && styles\.completedMarkerDot/u); + }); + + it('keeps map preview copy visually light without disabling the map gestures', () => { + const appSource = readFileSync(appRootPath, 'utf8'); + const nativeMapSource = readFileSync(nativeMapPath, 'utf8'); + + assert.doesNotMatch(appSource, /allowMapDragPan/u); + assert.match(nativeMapSource, /attribution=\{false\}/u); + assert.match(nativeMapSource, /compass=\{false\}/u); + assert.match(nativeMapSource, /scaleBar=\{false\}/u); + assert.match(nativeMapSource, /\sdragPan\s/u); + assert.match(nativeMapSource, /\stouchZoom\s/u); + assert.match(nativeMapSource, /\sdoubleTapZoom\s/u); + assert.match(nativeMapSource, /markerDot:[\s\S]*height: 12,[\s\S]*width: 12/u); + assert.match(nativeMapSource, /markerHalo:[\s\S]*height: 14,[\s\S]*width: 14/u); + assert.match(appSource, /Tap for full map/u); + assert.match(appSource, /routePreviewHeader/u); + assert.doesNotMatch(appSource, /Tap the preview for a larger map/u); + assert.doesNotMatch(appSource, /full interactive map/u); + assert.doesNotMatch(nativeMapSource, /Interactive map/u); + assert.doesNotMatch(nativeMapSource, /Pinch to zoom/u); + assert.doesNotMatch(nativeMapSource, /Drag to pan/u); + assert.doesNotMatch(nativeMapSource, /androidView="texture"/u); + }); + + it('opens the large map as a full-screen surface instead of a card', () => { + const appSource = readFileSync(appRootPath, 'utf8'); + + assert.match(appSource, /const isFullMapScreen = screen === 'liveMapPreview' && selectedRoute !== null/u); + assert.match(appSource, /style=\{styles\.fullScreenMap\}/u); + assert.match(appSource, /paddingTop: 34/u); + assert.match(appSource, /fullMapCanvas:[\s\S]*height: '100%'/u); + assert.doesNotMatch(appSource, /mapHeight/u); + assert.doesNotMatch(appSource, /contentContainerStyle=\{\[styles\.container/u); + assert.doesNotMatch(appSource, /liveMapPreviewCard/u); + }); +}); + + +describe('current task density', () => { + it('keeps Current Task buttons compact with normal-weight text and matching heights', () => { + const appSource = readFileSync(appRootPath, 'utf8'); + + assert.match(appSource, /compactButton:[\s\S]*minHeight: 42,[\s\S]*paddingVertical: 8,/u); + assert.match(appSource, /compactButtonText:[\s\S]*fontSize: 14,[\s\S]*fontWeight: '600'/u); + assert.match(appSource, /currentTaskAddressText:[\s\S]*fontSize: 14,[\s\S]*fontWeight: '400'/u); + }); +}); + +describe('stop completion proof copy', () => { + it('uses driver-facing photo labels instead of proof jargon', () => { + const appSource = readFileSync(appRootPath, 'utf8'); + + assert.match(appSource, /title="Complete Delivery"/u); + assert.match(appSource, />Delivery PhotoTake PhotoChoose from Album { + const appSource = readFileSync(appRootPath, 'utf8'); + + assert.match(appSource, /const buildDriverAccessRefresh = useCallback/u); + assert.match(appSource, /const getActiveAccountAccess = useCallback/u); + assert.match(appSource, /driverAuthService\.refreshSession/u); + assert.match(appSource, /driverAccessTokenStore\.saveRefreshedAccountAccess\(refreshResult\.accountAccess\)/u); + assert.match(appSource, /accountAccessToken: accountAccess\.accessToken/u); + assert.match(appSource, /refreshDriverAccess: buildDriverAccessRefresh\(submission\)/u); + assert.match(appSource, /refreshDriverAccess: buildDriverAccessRefresh\(choiceSubmission\)/u); + assert.match(appSource, /setRouteSessions\(\(current\) => current\.map/u); + assert.match(appSource, /\? refreshedSubmission/u); + assert.doesNotMatch(appSource, /refreshDriverAuthSessionForProofUpload/u); + assert.doesNotMatch(appSource, /uploadResult\.kind === 'upload_failed' && uploadResult\.reason === 'driver_access_expired'/u); + }); + + it('uses an in-app rear camera screen and requires uploaded proof media before completion', () => { + const appSource = readFileSync(appRootPath, 'utf8'); + const cameraSource = readFileSync(cameraCapturePath, 'utf8'); + + assert.match(appSource, /import \{ CameraView, useCameraPermissions \} from 'expo-camera'/u); + assert.match(appSource, /\| 'proofCamera'/u); + assert.match(appSource, /if \(source === 'camera'\) \{[\s\S]*setScreen\('proofCamera'\)/u); + assert.match(appSource, /GalleryFlash { + it('asks to enter the selected route session without treating it as pickup completion', () => { + let confirmed = false; + let alertPayload: Parameters< + Parameters< + typeof requestRouteStartSessionConfirmation + >[0]['alertApi']['alert'] + > | null = null; + + requestRouteStartSessionConfirmation({ + alertApi: { + alert: (...args) => { + alertPayload = args; + }, + }, + now: today, + route: { deliveryDate: '2026-06-15', timezone: 'Asia/Seoul' }, + onConfirm: () => { + confirmed = true; + }, + }); + + assert.notEqual(alertPayload, null); + const [title, message, buttons, options] = alertPayload!; + assert.equal(title, ROUTE_START_SESSION_CONFIRMATION.title); + assert.match(message, /opens the route session/i); + assert.match(message, /Pickup completion stays inside the session workflow/i); + assert.doesNotMatch(message, /inside Route Details/i); + assert.doesNotMatch(message, /Start pickup/i); + assert.doesNotMatch(message, /Warning:/i); + assert.deepEqual(options, { cancelable: true }); + assert.equal(buttons.length, 2); + assert.equal( + buttons[0]?.text, + ROUTE_START_SESSION_CONFIRMATION.cancelLabel, + ); + assert.equal(buttons[0]?.style, 'cancel'); + assert.equal( + buttons[1]?.text, + ROUTE_START_SESSION_CONFIRMATION.confirmLabel, + ); + assert.equal(buttons[1]?.style, 'default'); + + buttons[0]?.onPress?.(); + assert.equal(confirmed, false); + + buttons[1]?.onPress?.(); + assert.equal(confirmed, true); + }); + + it('warns before starting a past-dated route session', () => { + const message = buildRouteStartSessionConfirmationMessage( + { deliveryDate: '2026-06-14', timezone: 'Asia/Seoul' }, + today, + ); + + assert.match(message, /already passed/i); + assert.match(message, /2026-06-14/u); + }); + + it('warns before starting a route session scheduled for a different future day', () => { + const message = buildRouteStartSessionConfirmationMessage( + { deliveryDate: '2026-06-16', timezone: 'Asia/Seoul' }, + today, + ); + + assert.match(message, /scheduled for a different day/i); + assert.match(message, /2026-06-16/u); + }); + + it('uses the route timezone when deciding date warnings', () => { + const message = buildRouteStartSessionConfirmationMessage( + { deliveryDate: '2026-06-14', timezone: 'America/Toronto' }, + today, + ); + + assert.doesNotMatch(message, /Warning:/i); + }); +}); diff --git a/src/app/routeStartConfirmation.ts b/src/app/routeStartConfirmation.ts new file mode 100644 index 0000000..5d41905 --- /dev/null +++ b/src/app/routeStartConfirmation.ts @@ -0,0 +1,142 @@ +export const ROUTE_START_SESSION_CONFIRMATION = { + title: 'Start this session?', + baseMessage: + 'This opens the route session for the selected delivery. Pickup completion stays inside the session workflow.', + cancelLabel: 'Cancel', + confirmLabel: 'Start Session', +} as const; + +export type RouteStartConfirmationRoute = { + deliveryDate: string; + timezone: string; +}; + +type RouteStartConfirmationButton = { + onPress?: () => void; + style?: 'cancel' | 'default'; + text: string; +}; + +type RouteStartConfirmationAlert = { + alert( + title: string, + message: string, + buttons: RouteStartConfirmationButton[], + options: { cancelable: boolean }, + ): void; +}; + +export function requestRouteStartSessionConfirmation(input: { + alertApi: RouteStartConfirmationAlert; + now?: Date; + onConfirm(): void; + route: RouteStartConfirmationRoute; +}): void { + input.alertApi.alert( + ROUTE_START_SESSION_CONFIRMATION.title, + buildRouteStartSessionConfirmationMessage( + input.route, + input.now ?? new Date(), + ), + [ + { text: ROUTE_START_SESSION_CONFIRMATION.cancelLabel, style: 'cancel' }, + { + text: ROUTE_START_SESSION_CONFIRMATION.confirmLabel, + style: 'default', + onPress: input.onConfirm, + }, + ], + { cancelable: true }, + ); +} + +export function buildRouteStartSessionConfirmationMessage( + route: RouteStartConfirmationRoute, + now: Date, +): string { + const warning = getRouteDateWarning(route, now); + return warning === null + ? ROUTE_START_SESSION_CONFIRMATION.baseMessage + : `${ROUTE_START_SESSION_CONFIRMATION.baseMessage}\n\n${warning}`; +} + +function getRouteDateWarning( + route: RouteStartConfirmationRoute, + now: Date, +): string | null { + const routeDateKey = normalizeDeliveryDateKey(route.deliveryDate); + if (routeDateKey === null) { + return null; + } + + const todayKey = getTodayKeyForRoute(route.timezone, now); + if (routeDateKey < todayKey) { + return `Warning: this route date has already passed (${route.deliveryDate}). Start only if dispatch asked you to continue this session.`; + } + + if (routeDateKey > todayKey) { + return `Warning: this delivery is scheduled for a different day (${route.deliveryDate}). Start only if dispatch asked you to begin this session now.`; + } + + return null; +} + +function normalizeDeliveryDateKey(deliveryDate: string): string | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(deliveryDate.trim()); + if (match === null) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day); + + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + + return `${match[1]}-${match[2]}-${match[3]}`; +} + +function getTodayKeyForRoute(timezone: string, now: Date): string { + const trimmedTimezone = timezone.trim(); + if (trimmedTimezone.length > 0) { + const timezoneKey = toTimezoneDateKey(now, trimmedTimezone); + if (timezoneKey !== null) { + return timezoneKey; + } + } + + return [ + String(now.getFullYear()).padStart(4, '0'), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0'), + ].join('-'); +} + +function toTimezoneDateKey(date: Date, timezone: string): string | null { + try { + const parts = new Intl.DateTimeFormat('en-US', { + day: '2-digit', + month: '2-digit', + timeZone: timezone, + year: 'numeric', + }).formatToParts(date); + const year = parts.find((part) => part.type === 'year')?.value; + const month = parts.find((part) => part.type === 'month')?.value; + const day = parts.find((part) => part.type === 'day')?.value; + + if (year === undefined || month === undefined || day === undefined) { + return null; + } + + return `${year}-${month}-${day}`; + } catch { + return null; + } +} diff --git a/src/app/stopDetailsBehavior.test.ts b/src/app/stopDetailsBehavior.test.ts new file mode 100644 index 0000000..c2d51fc --- /dev/null +++ b/src/app/stopDetailsBehavior.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const appRootPath = join(dirname(fileURLToPath(import.meta.url)), 'AppRoot.tsx'); + +function getAppRootSource(): string { + return readFileSync(appRootPath, 'utf8'); +} + +function getStopDetailsComponentSource(): string { + const source = getAppRootSource(); + const start = source.indexOf('function StopDetailsScreen('); + const end = source.indexOf('function ArrivalCheckScreen(', start); + + assert.notEqual(start, -1); + assert.notEqual(end, -1); + + return source.slice(start, end); +} + +describe('stop details simplification', () => { + it('renders only concise read-only stop reference content', () => { + const componentSource = getStopDetailsComponentSource(); + + assert.match(componentSource, /formatStopStreetAddress\(stop\)/u); + assert.match(componentSource, /StatusChip label=\{payment\.label\} tone=\{payment\.tone\}/u); + assert.match(componentSource, /Items to drop/u); + assert.match(componentSource, /formatAssignedRouteItemLine\(item\)/u); + assert.match(componentSource, /No delivery instructions provided\./u); + assert.match(componentSource, /No location tips provided\./u); + assert.match(componentSource, /label="Open Stop Map"/u); + assert.match(componentSource, /label="Call"/u); + assert.match(componentSource, /label="Message"/u); + + assert.doesNotMatch(componentSource, /payment\.detail/u); + assert.doesNotMatch(componentSource, /Delivery instructions are provided by dispatch/u); + assert.doesNotMatch(componentSource, /getNavigationTip/u); + assert.doesNotMatch(componentSource, /label="Arrived"/u); + assert.doesNotMatch(componentSource, /I’m Nearby/u); + assert.doesNotMatch(componentSource, /canMarkArrived/u); + assert.doesNotMatch(componentSource, /onAnnounceTip/u); + }); + + it('uses a basic street address on Stop Details without hiding full address formatting elsewhere', () => { + const source = getAppRootSource(); + + assert.match(source, /function formatStopStreetAddress\(stop: AssignedRouteStop\): string/u); + assert.match(source, /\[stop\.address\.address1, stop\.address\.address2\]/u); + assert.match(source, /streetAddress\.length === 0 \? formatStopAddress\(stop\) : streetAddress/u); + }); + + it('restores horizontal swipe-back through the shared app back decision', () => { + const source = getAppRootSource(); + + assert.match(source, /PanResponder/u); + assert.match(source, /const handleAppBack = useCallback\(\(\): boolean =>/u); + assert.match(source, /BackHandler\.addEventListener\('hardwareBackPress', handleAppBack\)/u); + assert.match(source, /const swipeBackResponder = useMemo\(\(\) => PanResponder\.create/u); + assert.match(source, /SWIPE_BACK_DIRECTIONALITY_RATIO/u); + assert.match(source, /SWIPE_BACK_DISTANCE/u); + assert.match(source, /SWIPE_BACK_EDGE_WIDTH/u); + assert.match(source, /screen === 'liveMapPreview'/u); + assert.match(source, /gestureState\.x0 > SWIPE_BACK_EDGE_WIDTH/u); + assert.match(source, /SWIPE_BACK_MAX_VERTICAL_DELTA/u); + assert.match(source, /\.\.\.swipeBackResponder\.panHandlers/u); + assert.match(source, /case 'stopDetails':[\s\S]*setSelectedStopDetailsId\(null\);[\s\S]*setScreen\(stopDetailsBackTarget\);[\s\S]*return true;/u); + assert.match(source, / \{[\s\S]*handleAppBack\(\);[\s\S]*\}\}/u); + assert.doesNotMatch(source, /trackingDeckPanResponder/u); + }); +}); diff --git a/src/app/verifiedDriverNoAssignedRoutes.test.ts b/src/app/verifiedDriverNoAssignedRoutes.test.ts new file mode 100644 index 0000000..091ace9 --- /dev/null +++ b/src/app/verifiedDriverNoAssignedRoutes.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + getVerifiedDriverNoAssignedRouteMessage, +} from './verifiedDriverNoAssignedRoutes'; + +describe('signed-in driver with no assigned routes', () => { + it('uses route-empty copy instead of auth failure copy', () => { + assert.match(getVerifiedDriverNoAssignedRouteMessage('no_route_choices'), /Signed in/); + assert.match(getVerifiedDriverNoAssignedRouteMessage('route_lookup_not_found'), /Refresh routes/); + assert.match(getVerifiedDriverNoAssignedRouteMessage('assigned_route_load_empty'), /dispatch publishes/); + assert.match(getVerifiedDriverNoAssignedRouteMessage('past_routes_hidden'), /Past routes are hidden/); + }); +}); diff --git a/src/app/verifiedDriverNoAssignedRoutes.ts b/src/app/verifiedDriverNoAssignedRoutes.ts new file mode 100644 index 0000000..13fb2b3 --- /dev/null +++ b/src/app/verifiedDriverNoAssignedRoutes.ts @@ -0,0 +1,17 @@ +export type VerifiedDriverNoAssignedRouteReason = + | 'no_route_choices' + | 'route_lookup_not_found' + | 'assigned_route_load_empty' + | 'past_routes_hidden'; + +export function getVerifiedDriverNoAssignedRouteMessage(reason: VerifiedDriverNoAssignedRouteReason): string { + switch (reason) { + case 'no_route_choices': + case 'route_lookup_not_found': + return 'Signed in. No current or upcoming route is assigned right now. Pull down or tap Refresh routes after dispatch assigns a route.'; + case 'assigned_route_load_empty': + return 'Signed in. Assigned routes are not ready yet. Pull down or tap Refresh routes after dispatch publishes the route.'; + case 'past_routes_hidden': + return 'Signed in. Past routes are hidden and no current or upcoming route is assigned right now. Pull down or tap Refresh routes after dispatch assigns a route.'; + } +} diff --git a/src/domain/driver/driverAccessTokenStore.test.ts b/src/domain/driver/driverAccessTokenStore.test.ts index e904e9b..e21886a 100644 --- a/src/domain/driver/driverAccessTokenStore.test.ts +++ b/src/domain/driver/driverAccessTokenStore.test.ts @@ -1,70 +1,185 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import type { DriverAccountAccessToken } from '../driverAuth/driverAuth'; +import { sampleInvitedRouteAccess } from '../routeAccess/routeAccess'; import { createDriverAccessTokenStore, DRIVER_ACCESS_TOKEN_STORAGE_KEY, type SecureTokenStorage, } from './driverAccessTokenStore'; -import { sampleInvitedRouteAccess } from '../routeAccess/routeAccess'; -function createMemoryStorage(seed: Record = {}): SecureTokenStorage & { values: Record } { +function createMemoryStorage(seed: Record = {}): SecureTokenStorage & { + values: Record; +} { const values = { ...seed }; return { values, - deleteItemAsync: async (key) => { - values[key] = null; - }, + deleteItemAsync: async (key) => { values[key] = null; }, getItemAsync: async (key) => values[key] ?? null, - setItemAsync: async (key, value) => { - values[key] = value; - }, + setItemAsync: async (key, value) => { values[key] = value; }, }; } -test('saves route lookup driver access and restores it before expiry', async () => { +function accountAccess(overrides: Partial = {}): DriverAccountAccessToken { + return { + accessToken: 'account-access-token', + expiresAt: '2026-05-12T07:00:00.000Z', + refreshToken: 'account-refresh-token', + refreshTokenExpiresAt: '2026-06-12T07:00:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', + ...overrides, + }; +} + +async function saveAccount( + store: ReturnType, + access = accountAccess(), +): Promise { + await store.saveAuthenticatedDriver({ + accountAccess: access, + phoneE164: '+821089216198', + }); +} + +test('saves a phone account before any route is assigned', async () => { const storage = createMemoryStorage(); const store = createDriverAccessTokenStore({ now: () => new Date('2026-05-12T06:45:00.000Z'), storage, }); + await saveAccount(store); + const restored = await store.loadActiveDriverAccess(); + + assert.equal(restored.kind, 'active'); + if (restored.kind !== 'active') return; + assert.equal(restored.accountAccess.use, 'driver_account'); + assert.deepEqual(restored.driverProfile, { phoneE164: '+821089216198' }); + assert.equal(restored.driverAccess, undefined); + assert.equal(restored.routeAccess, undefined); +}); + +test('keeps account access separate when saving selected route access', async () => { + const storage = createMemoryStorage(); + const store = createDriverAccessTokenStore({ + now: () => new Date('2026-05-12T06:45:00.000Z'), + storage, + }); + + await saveAccount(store); await store.saveFromInvitedRouteAccess(sampleInvitedRouteAccess); const restored = await store.loadActiveDriverAccess(); assert.equal(restored.kind, 'active'); - if (restored.kind !== 'active') { - return; - } + if (restored.kind !== 'active') return; + assert.equal(restored.accountAccess.accessToken, 'account-access-token'); + assert.equal(restored.driverAccess?.accessToken, 'fixture-driver-access-token'); assert.deepEqual(restored.routeAccess, sampleInvitedRouteAccess.routeAccess); - assert.deepEqual(restored.driverAccess, sampleInvitedRouteAccess.driverAccess); }); -test('clears and refuses to restore an expired driver access token', async () => { +test('refreshes only account access and preserves route and active session', async () => { const storage = createMemoryStorage(); const store = createDriverAccessTokenStore({ - now: () => new Date('2026-05-12T07:00:00.000Z'), + now: () => new Date('2026-05-12T06:45:00.000Z'), storage, }); + await saveAccount(store); await store.saveFromInvitedRouteAccess(sampleInvitedRouteAccess); + await store.saveActiveRouteSession({ navigationStepIndex: 2, routePlanId: sampleInvitedRouteAccess.routeAccess.routePlanId }); + await store.saveRefreshedAccountAccess(accountAccess({ accessToken: 'refreshed-account-access' })); const restored = await store.loadActiveDriverAccess(); - assert.deepEqual(restored, { kind: 'expired' }); - assert.equal(storage.values[DRIVER_ACCESS_TOKEN_STORAGE_KEY], null); + assert.equal(restored.kind, 'active'); + if (restored.kind !== 'active') return; + assert.equal(restored.accountAccess.accessToken, 'refreshed-account-access'); + assert.equal(restored.driverAccess?.accessToken, 'fixture-driver-access-token'); + assert.equal(restored.activeRouteSession?.navigationStepIndex, 2); }); -test('clears malformed persisted token payloads instead of reusing them', async () => { - const storage = createMemoryStorage({ - [DRIVER_ACCESS_TOKEN_STORAGE_KEY]: JSON.stringify({ schemaVersion: 1, driverAccess: { accessToken: 'missing fields' } }), +test('clears only the active route session without signing out', async () => { + const storage = createMemoryStorage(); + const store = createDriverAccessTokenStore({ + now: () => new Date('2026-05-12T06:45:00.000Z'), + storage, }); + + await saveAccount(store); + await store.saveFromInvitedRouteAccess(sampleInvitedRouteAccess); + await store.saveActiveRouteSession({ navigationStepIndex: 1, routePlanId: sampleInvitedRouteAccess.routeAccess.routePlanId }); + await store.clearActiveRouteSession(); + const restored = await store.loadActiveDriverAccess(); + + assert.equal(restored.kind, 'active'); + if (restored.kind !== 'active') return; + assert.equal(restored.activeRouteSession, undefined); + assert.deepEqual(restored.routeAccess, sampleInvitedRouteAccess.routeAccess); + assert.equal(restored.accountAccess.accessToken, 'account-access-token'); +}); + +test('clears deleted route cache while keeping the account signed in', async () => { + const storage = createMemoryStorage(); const store = createDriverAccessTokenStore({ now: () => new Date('2026-05-12T06:45:00.000Z'), storage, }); + await saveAccount(store); + await store.saveFromInvitedRouteAccess(sampleInvitedRouteAccess); + await store.saveActiveRouteSession({ navigationStepIndex: 1, routePlanId: sampleInvitedRouteAccess.routeAccess.routePlanId }); + await store.clearCachedRouteAccess(); + const restored = await store.loadActiveDriverAccess(); + + assert.equal(restored.kind, 'active'); + if (restored.kind !== 'active') return; + assert.equal(restored.accountAccess.accessToken, 'account-access-token'); + assert.equal(restored.driverAccess, undefined); + assert.equal(restored.routeAccess, undefined); + assert.equal(restored.activeRouteSession, undefined); +}); + +test('returns refresh_required when account access expires but refresh remains valid', async () => { + const storage = createMemoryStorage(); + const store = createDriverAccessTokenStore({ + now: () => new Date('2026-05-12T07:00:00.000Z'), + storage, + }); + + await saveAccount(store); + const restored = await store.loadActiveDriverAccess(); + assert.equal(restored.kind, 'refresh_required'); +}); + +test('clears an account session after both access and refresh expire', async () => { + const storage = createMemoryStorage(); + const store = createDriverAccessTokenStore({ + now: () => new Date('2026-06-13T07:00:00.000Z'), + storage, + }); + + await saveAccount(store); const restored = await store.loadActiveDriverAccess(); - assert.deepEqual(restored, { kind: 'invalid' }); + assert.deepEqual(restored, { + driverProfile: { phoneE164: '+821089216198' }, + kind: 'expired', + }); + assert.equal(storage.values[DRIVER_ACCESS_TOKEN_STORAGE_KEY], null); +}); + +test('invalidates legacy and malformed token payloads', async () => { + const storage = createMemoryStorage({ + [DRIVER_ACCESS_TOKEN_STORAGE_KEY]: JSON.stringify({ + driverAccess: sampleInvitedRouteAccess.driverAccess, + savedAt: '2026-05-12T06:45:00.000Z', + schemaVersion: 3, + }), + }); + const store = createDriverAccessTokenStore({ storage }); + + assert.deepEqual(await store.loadActiveDriverAccess(), { kind: 'invalid' }); assert.equal(storage.values[DRIVER_ACCESS_TOKEN_STORAGE_KEY], null); }); diff --git a/src/domain/driver/driverAccessTokenStore.ts b/src/domain/driver/driverAccessTokenStore.ts index 12d41f8..36b487a 100644 --- a/src/domain/driver/driverAccessTokenStore.ts +++ b/src/domain/driver/driverAccessTokenStore.ts @@ -1,3 +1,4 @@ +import type { DriverAccountAccessToken } from '../driverAuth/driverAuth'; import { isDriverAccessToken, type DriverAccessToken, @@ -13,23 +14,52 @@ export type SecureTokenStorage = { }; export type PersistedDriverAccess = { - driverAccess: DriverAccessToken; - routeAccess: Extract['routeAccess']; + accountAccess: DriverAccountAccessToken; + activeRouteSession?: PersistedActiveRouteSession; + driverAccess?: DriverAccessToken; + driverProfile: PersistedDriverProfile; + routeAccess?: Extract['routeAccess']; +}; + +export type PersistedActiveRouteSession = { + navigationStepIndex: number; + routePlanId: string; + status: 'active'; + updatedAt: string; +}; + +export type PersistedDriverProfile = { + phoneE164: string; }; export type DriverAccessRestoreResult = | ({ kind: 'active' } & PersistedDriverAccess) - | { kind: 'expired' | 'invalid' | 'missing' }; + | ({ kind: 'refresh_required' } & PersistedDriverAccess) + | { driverProfile?: PersistedDriverProfile; kind: 'expired' } + | { kind: 'invalid' | 'missing' }; type StoredDriverAccessPayload = PersistedDriverAccess & { - schemaVersion: 1; + schemaVersion: 4; savedAt: string; }; export type DriverAccessTokenStore = { clear(): Promise; + clearActiveRouteSession(): Promise; + clearCachedRouteAccess(): Promise; loadActiveDriverAccess(): Promise; - saveFromInvitedRouteAccess(routeAccess: Extract): Promise; + saveActiveRouteSession(input: { + navigationStepIndex: number; + routePlanId: string; + }): Promise; + saveAuthenticatedDriver(input: { + accountAccess: DriverAccountAccessToken; + phoneE164: string; + }): Promise; + saveFromInvitedRouteAccess( + routeAccess: Extract, + ): Promise; + saveRefreshedAccountAccess(accountAccess: DriverAccountAccessToken): Promise; }; export function createDriverAccessTokenStore(input: { @@ -42,8 +72,44 @@ export function createDriverAccessTokenStore(input: { await input.storage.deleteItemAsync(DRIVER_ACCESS_TOKEN_STORAGE_KEY); } + async function loadStoredPayload(): Promise { + const rawPayload = await input.storage.getItemAsync(DRIVER_ACCESS_TOKEN_STORAGE_KEY); + return rawPayload === null ? null : parseStoredDriverAccessPayload(rawPayload); + } + + async function updateStoredPayload( + updater: (payload: StoredDriverAccessPayload) => StoredDriverAccessPayload, + ): Promise { + const storedPayload = await loadStoredPayload(); + if (storedPayload === null) { + return; + } + + await input.storage.setItemAsync( + DRIVER_ACCESS_TOKEN_STORAGE_KEY, + JSON.stringify(updater(storedPayload)), + ); + } + return { clear, + clearActiveRouteSession: async () => { + await updateStoredPayload((payload) => { + const { activeRouteSession: _activeRouteSession, ...rest } = payload; + return { ...rest, savedAt: now().toISOString() }; + }); + }, + clearCachedRouteAccess: async () => { + await updateStoredPayload((payload) => { + const { + activeRouteSession: _activeRouteSession, + driverAccess: _driverAccess, + routeAccess: _routeAccess, + ...rest + } = payload; + return { ...rest, savedAt: now().toISOString() }; + }); + }, loadActiveDriverAccess: async () => { const rawPayload = await input.storage.getItemAsync(DRIVER_ACCESS_TOKEN_STORAGE_KEY); if (rawPayload === null) { @@ -56,64 +122,164 @@ export function createDriverAccessTokenStore(input: { return { kind: 'invalid' }; } - if (isDriverAccessExpired(payload.driverAccess, now())) { + if (isDriverAccessExpired(payload.accountAccess, now())) { + if (isDriverRefreshTokenValid(payload.accountAccess, now())) { + return buildDriverAccessRestoreResult('refresh_required', payload); + } + await clear(); - return { kind: 'expired' }; + return { kind: 'expired', driverProfile: payload.driverProfile }; } - return { - kind: 'active', - driverAccess: payload.driverAccess, - routeAccess: payload.routeAccess, - }; + return buildDriverAccessRestoreResult('active', payload); }, - saveFromInvitedRouteAccess: async (routeAccess) => { + saveActiveRouteSession: async (activeRouteSession) => { + const navigationStepIndex = Number.isInteger(activeRouteSession.navigationStepIndex) && + activeRouteSession.navigationStepIndex >= 0 + ? activeRouteSession.navigationStepIndex + : 0; + + await updateStoredPayload((payload) => ({ + ...payload, + savedAt: now().toISOString(), + activeRouteSession: { + navigationStepIndex, + routePlanId: activeRouteSession.routePlanId, + status: 'active', + updatedAt: now().toISOString(), + }, + })); + }, + saveAuthenticatedDriver: async (driver) => { const payload: StoredDriverAccessPayload = { - schemaVersion: 1, + accountAccess: driver.accountAccess, + driverProfile: { phoneE164: driver.phoneE164.trim() }, savedAt: now().toISOString(), - driverAccess: routeAccess.driverAccess, - routeAccess: routeAccess.routeAccess, + schemaVersion: 4, }; - await input.storage.setItemAsync(DRIVER_ACCESS_TOKEN_STORAGE_KEY, JSON.stringify(payload)); + await input.storage.setItemAsync( + DRIVER_ACCESS_TOKEN_STORAGE_KEY, + JSON.stringify(payload), + ); + }, + saveFromInvitedRouteAccess: async (routeAccess) => { + await updateStoredPayload((payload) => ({ + ...payload, + driverAccess: routeAccess.driverAccess, + routeAccess: routeAccess.routeAccess, + savedAt: now().toISOString(), + })); + }, + saveRefreshedAccountAccess: async (accountAccess) => { + await updateStoredPayload((payload) => ({ + ...payload, + accountAccess, + savedAt: now().toISOString(), + })); }, }; } -export function isDriverAccessExpired(driverAccess: DriverAccessToken, now: Date): boolean { +export function isDriverAccessExpired( + driverAccess: Pick, + now: Date, +): boolean { const expiresAtMs = Date.parse(driverAccess.expiresAt); return !Number.isFinite(expiresAtMs) || expiresAtMs <= now.getTime(); } +export function isDriverRefreshTokenValid( + accountAccess: Pick, + now: Date, +): boolean { + const expiresAtMs = Date.parse(accountAccess.refreshTokenExpiresAt); + return Number.isFinite(expiresAtMs) && expiresAtMs > now.getTime(); +} + function parseStoredDriverAccessPayload(rawPayload: string): StoredDriverAccessPayload | null { try { const payload: unknown = JSON.parse(rawPayload); - if (!isStoredDriverAccessPayload(payload)) { - return null; - } - - return payload; + return isStoredDriverAccessPayload(payload) ? payload : null; } catch { return null; } } +function buildDriverAccessRestoreResult( + kind: 'active' | 'refresh_required', + payload: StoredDriverAccessPayload, +): Extract { + const { savedAt: _savedAt, schemaVersion: _schemaVersion, ...access } = payload; + return { kind, ...access }; +} + function isStoredDriverAccessPayload(value: unknown): value is StoredDriverAccessPayload { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; } const payload = value as Record; - const routeAccess = payload.routeAccess; return ( - payload.schemaVersion === 1 && + payload.schemaVersion === 4 && typeof payload.savedAt === 'string' && - isDriverAccessToken(payload.driverAccess) && - typeof routeAccess === 'object' && - routeAccess !== null && - !Array.isArray(routeAccess) && - (routeAccess as Record).nextState === 'consent_required' && - typeof (routeAccess as Record).routeContext === 'string' && - typeof (routeAccess as Record).routePlanId === 'string' + isDriverAccountAccessToken(payload.accountAccess) && + isPersistedDriverProfile(payload.driverProfile) && + (payload.driverAccess === undefined || isDriverAccessToken(payload.driverAccess)) && + (payload.routeAccess === undefined || isPersistedRouteAccess(payload.routeAccess)) && + (payload.activeRouteSession === undefined || isPersistedActiveRouteSession(payload.activeRouteSession)) + ); +} + +function isDriverAccountAccessToken(value: unknown): value is DriverAccountAccessToken { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const token = value as Record; + return ( + typeof token.accessToken === 'string' && token.accessToken.trim() !== '' && + typeof token.expiresAt === 'string' && Number.isFinite(Date.parse(token.expiresAt)) && + typeof token.refreshToken === 'string' && token.refreshToken.trim() !== '' && + typeof token.refreshTokenExpiresAt === 'string' && Number.isFinite(Date.parse(token.refreshTokenExpiresAt)) && + token.tokenType === 'Bearer' && + typeof token.ttlSeconds === 'number' && Number.isInteger(token.ttlSeconds) && token.ttlSeconds > 0 && + token.use === 'driver_account' + ); +} + +function isPersistedRouteAccess( + value: unknown, +): value is NonNullable { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + (value as Record).nextState === 'consent_required' && + typeof (value as Record).routeContext === 'string' && + typeof (value as Record).routePlanId === 'string' + ); +} + +function isPersistedDriverProfile(value: unknown): value is PersistedDriverProfile { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const profile = value as Record; + return typeof profile.phoneE164 === 'string' && /^\+[1-9]\d{7,14}$/u.test(profile.phoneE164.trim()); +} + +function isPersistedActiveRouteSession(value: unknown): value is PersistedActiveRouteSession { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const session = value as Record; + return ( + session.status === 'active' && + typeof session.routePlanId === 'string' && session.routePlanId.trim() !== '' && + Number.isInteger(session.navigationStepIndex) && (session.navigationStepIndex as number) >= 0 && + typeof session.updatedAt === 'string' && Number.isFinite(Date.parse(session.updatedAt)) ); } diff --git a/src/domain/driver/driverAccountAccessTokenStore.contract.test.ts b/src/domain/driver/driverAccountAccessTokenStore.contract.test.ts new file mode 100644 index 0000000..8bc7127 --- /dev/null +++ b/src/domain/driver/driverAccountAccessTokenStore.contract.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { it } from 'node:test'; + +import { createDriverAccessTokenStore, type SecureTokenStorage } from './driverAccessTokenStore'; +import { sampleInvitedRouteAccess } from '../routeAccess/routeAccess'; + +it('keeps account auth separate from the selected route driver token', async () => { + const values: Record = {}; + const storage: SecureTokenStorage = { + deleteItemAsync: async (key) => { values[key] = null; }, + getItemAsync: async (key) => values[key] ?? null, + setItemAsync: async (key, value) => { values[key] = value; }, + }; + const store = createDriverAccessTokenStore({ + now: () => new Date('2026-07-14T07:00:00.000Z'), + storage, + }); + + await store.saveAuthenticatedDriver({ + accountAccess: { + accessToken: 'account-access-token', + expiresAt: '2026-07-14T07:15:00.000Z', + refreshToken: 'account-refresh-token', + refreshTokenExpiresAt: '2026-08-13T07:00:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', + }, + phoneE164: '+821012345678', + }); + await store.saveFromInvitedRouteAccess(sampleInvitedRouteAccess); + + const restored = await store.loadActiveDriverAccess(); + assert.equal(restored.kind, 'active'); + if (restored.kind !== 'active') return; + assert.equal(restored.accountAccess.accessToken, 'account-access-token'); + assert.equal(restored.driverAccess?.accessToken, sampleInvitedRouteAccess.driverAccess.accessToken); + assert.deepEqual(restored.driverProfile, { phoneE164: '+821012345678' }); +}); diff --git a/src/domain/driverAuth/driverAccountAuth.contract.test.ts b/src/domain/driverAuth/driverAccountAuth.contract.test.ts new file mode 100644 index 0000000..98b7755 --- /dev/null +++ b/src/domain/driverAuth/driverAccountAuth.contract.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createDriverAuthApiClient } from './driverAuth'; + +const accountEnvelope = { + data: { + accessToken: 'account-access-token', + expiresAt: '2026-07-14T08:00:00.000Z', + refreshToken: 'account-refresh-token', + refreshTokenExpiresAt: '2026-08-13T08:00:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', + }, + error: null, +}; + +describe('driver phone PIN auth contract', () => { + it('logs an existing account in with phone and six-digit PIN', async () => { + let requestUrl = ''; + let requestBody: unknown; + const client = createDriverAuthApiClient({ + baseUrl: 'https://delivery.example.com', + fetchImpl: async (url, init) => { + requestUrl = url; + requestBody = JSON.parse(init?.body ?? '{}') as unknown; + return { json: async () => accountEnvelope, ok: true, status: 200 }; + }, + }); + + const result = await client.login({ phoneE164: '+821012345678', pin: '012345' }); + + assert.equal(requestUrl, 'https://delivery.example.com/driver/auth/login'); + assert.deepEqual(requestBody, { phone: '+821012345678', pin: '012345' }); + assert.equal(result.accountAccess.use, 'driver_account'); + }); + + it('registers a first-time account without sending a display name', async () => { + let requestBody: Record = {}; + const client = createDriverAuthApiClient({ + baseUrl: 'https://delivery.example.com', + fetchImpl: async (_url, init) => { + requestBody = JSON.parse(init?.body ?? '{}') as Record; + return { json: async () => accountEnvelope, ok: true, status: 200 }; + }, + }); + + await client.register({ phoneE164: '+821012345678', inviteCode: 'ABC123', pin: '012345' }); + + assert.deepEqual(requestBody, { + phone: '+821012345678', + inviteCode: 'ABC123', + pin: '012345', + }); + assert.equal('displayName' in requestBody, false); + }); +}); diff --git a/src/domain/driverAuth/driverAuth.test.ts b/src/domain/driverAuth/driverAuth.test.ts new file mode 100644 index 0000000..125b00f --- /dev/null +++ b/src/domain/driverAuth/driverAuth.test.ts @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { createDriverAuthApiClient, createMockDriverAuthService } from './driverAuth'; + +describe('DriverAuthService', () => { + it('registers with invite and PIN and parses account tokens', async () => { + let requestBody: any; + const client = createDriverAuthApiClient({ + baseUrl: 'https://test-api.com', + fetchImpl: async (url: string, init?: any) => { + requestBody = JSON.parse(init.body); + return { + ok: true, + status: 200, + json: async () => ({ + data: { + accessToken: 'mock-at', + expiresAt: '2026-05-15T00:00:00.000Z', + refreshToken: 'mock-rt', + refreshTokenExpiresAt: '2026-06-15T00:00:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', + }, + }), + }; + }, + }); + + const result = await client.register({ + phoneE164: '+1234567890', + inviteCode: '123456', + pin: '654321', + }); + + assert.equal(requestBody.phone, '+1234567890'); + assert.equal(requestBody.inviteCode, '123456'); + assert.equal(requestBody.pin, '654321'); + assert.equal('displayName' in requestBody, false); + assert.equal(result.accountAccess.accessToken, 'mock-at'); + assert.equal(result.accountAccess.refreshToken, 'mock-rt'); + assert.equal(result.accountAccess.use, 'driver_account'); + }); + + it('refreshes driver access with the stored refresh token', async () => { + let requestBody: any; + let requestUrl = ''; + const client = createDriverAuthApiClient({ + baseUrl: 'https://test-api.com/', + fetchImpl: async (url: string, init?: any) => { + requestUrl = url; + requestBody = JSON.parse(init.body); + return { + ok: true, + status: 200, + json: async () => ({ + data: { + accessToken: 'refreshed-at', + expiresAt: '2026-05-15T00:15:00.000Z', + refreshToken: 'stored-rt', + refreshTokenExpiresAt: '2026-06-15T00:00:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', + }, + }), + }; + }, + }); + + const result = await client.refreshSession({ refreshToken: ' stored-rt ' }); + + assert.equal(requestUrl, 'https://test-api.com/driver/auth/refresh'); + assert.deepEqual(requestBody, { refreshToken: 'stored-rt' }); + assert.equal(result.accountAccess.accessToken, 'refreshed-at'); + assert.equal(result.accountAccess.refreshToken, 'stored-rt'); + assert.equal(result.accountAccess.use, 'driver_account'); + }); + + it('provides a local mock PIN login without pretending to send SMS', async () => { + const client = createMockDriverAuthService(); + + const result = await client.login({ + phoneE164: '+1234567890', + pin: '654321', + }); + + assert.equal(result.accountAccess.accessToken, 'fixture-driver-account-access-token'); + }); +}); diff --git a/src/domain/driverAuth/driverAuth.ts b/src/domain/driverAuth/driverAuth.ts new file mode 100644 index 0000000..2df7208 --- /dev/null +++ b/src/domain/driverAuth/driverAuth.ts @@ -0,0 +1,133 @@ +import { createDriverApiHttpError } from '../../api/deliveryServer/driverApiError'; +import { withNoStoreDriverApiRequest } from '../../api/deliveryServer/driverApiRequestOptions'; + +export type DriverAccountAccessToken = { + accessToken: string; + expiresAt: string; + refreshToken: string; + refreshTokenExpiresAt: string; + tokenType: 'Bearer'; + ttlSeconds: number; + use: 'driver_account'; +}; + +export type LoginDriverAccountInput = { + phoneE164: string; + pin: string; +}; + +export type RegisterDriverAccountInput = LoginDriverAccountInput & { + inviteCode: string; +}; + +export type RefreshDriverAuthSessionInput = { + refreshToken: string; +}; + +export type DriverAuthService = { + login(input: LoginDriverAccountInput): Promise<{ accountAccess: DriverAccountAccessToken }>; + refreshSession(input: RefreshDriverAuthSessionInput): Promise<{ accountAccess: DriverAccountAccessToken }>; + register(input: RegisterDriverAccountInput): Promise<{ accountAccess: DriverAccountAccessToken }>; +}; + +export type FetchLike = ( + input: string, + init?: { + body?: string; + cache?: 'no-store'; + credentials?: 'omit'; + headers?: Record; + method?: string; + }, +) => Promise<{ + json(): Promise; + ok: boolean; + status?: number; +}>; + +export function createDriverAuthApiClient(input: { + baseUrl: string; + fetchImpl?: FetchLike; +}): DriverAuthService { + const baseUrl = input.baseUrl.replace(/\/$/u, ''); + const fetchImpl = input.fetchImpl ?? globalThis.fetch; + + async function postAuth(endpoint: string, body: Record, label: string) { + const response = await fetchImpl(`${baseUrl}${endpoint}`, withNoStoreDriverApiRequest({ + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + })); + const payload = await response.json(); + if (!response.ok) { + throw createDriverApiHttpError({ endpoint: label, status: response.status }); + } + + return { accountAccess: readDriverAuthEnvelope(payload) }; + } + + return { + login: (request) => postAuth('/driver/auth/login', { + phone: request.phoneE164.trim(), + pin: request.pin.trim(), + }, 'Driver PIN login'), + refreshSession: (request) => postAuth('/driver/auth/refresh', { + refreshToken: request.refreshToken.trim(), + }, 'Refresh Auth Session'), + register: (request) => postAuth('/driver/auth/verify-invite', { + phone: request.phoneE164.trim(), + inviteCode: request.inviteCode.trim().toUpperCase(), + pin: request.pin.trim(), + }, 'Register Driver Account'), + }; +} + +export function createMockDriverAuthService(accountAccess: DriverAccountAccessToken = { + accessToken: 'fixture-driver-account-access-token', + expiresAt: '2100-05-12T06:55:00.000Z', + refreshToken: 'fixture-driver-account-refresh-token', + refreshTokenExpiresAt: '2100-06-11T06:55:00.000Z', + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'driver_account', +}): DriverAuthService { + return { + login: async () => ({ accountAccess }), + refreshSession: async () => ({ accountAccess }), + register: async () => ({ accountAccess }), + }; +} + +function readDriverAuthEnvelope(payload: unknown): DriverAccountAccessToken { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + throw new Error('Invalid driver auth response'); + } + + const data = (payload as { data?: unknown }).data; + if (typeof data !== 'object' || data === null || Array.isArray(data)) { + throw new Error('Invalid driver auth response'); + } + + const record = data as Record; + if ( + typeof record.accessToken !== 'string' || record.accessToken.trim() === '' || + typeof record.expiresAt !== 'string' || !Number.isFinite(Date.parse(record.expiresAt)) || + typeof record.refreshToken !== 'string' || record.refreshToken.trim() === '' || + typeof record.refreshTokenExpiresAt !== 'string' || !Number.isFinite(Date.parse(record.refreshTokenExpiresAt)) || + record.tokenType !== 'Bearer' || + typeof record.ttlSeconds !== 'number' || !Number.isInteger(record.ttlSeconds) || record.ttlSeconds <= 0 || + record.use !== 'driver_account' + ) { + throw new Error('Invalid driver auth response'); + } + + return { + accessToken: record.accessToken, + expiresAt: record.expiresAt, + refreshToken: record.refreshToken, + refreshTokenExpiresAt: record.refreshTokenExpiresAt, + tokenType: 'Bearer', + ttlSeconds: record.ttlSeconds, + use: 'driver_account', + }; +} diff --git a/src/domain/driverFlow/driverFlow.test.ts b/src/domain/driverFlow/driverFlow.test.ts index 4a40581..e1c6063 100644 --- a/src/domain/driverFlow/driverFlow.test.ts +++ b/src/domain/driverFlow/driverFlow.test.ts @@ -5,7 +5,6 @@ import { canEnterDeliveryActive, canRevealRouteDetails, DRIVER_FLOW_STATES, - getInitialAccessValidation, getMvpRouteTabs, getMvpScenarioScreens, getStopCompletionProofFields, @@ -26,24 +25,6 @@ describe('driver app MVP flow', () => { ]); }); - it('accepts phone-only access as the lookup starting point', () => { - assert.deepEqual( - getInitialAccessValidation({ phoneE164: '+14165550123' }), - { - ok: true, - }, - ); - }); - - it('still accepts route context plus E.164 phone as an optional narrowed lookup', () => { - assert.deepEqual( - getInitialAccessValidation({ routeContext: 'route-tomato-2026-05-12', phoneE164: '+14165550123' }), - { - ok: true, - }, - ); - }); - it('does not reveal route details before consent is recorded', () => { assert.equal(canRevealRouteDetails('company_context_confirmed'), false); assert.equal(canRevealRouteDetails('consent_required'), false); @@ -63,6 +44,7 @@ describe('driver app MVP flow', () => { 'login', 'routeList', 'routeDetail', + 'routeSession', 'liveTracking', 'stopDetails', 'arrivalCheck', @@ -75,8 +57,9 @@ describe('driver app MVP flow', () => { getMvpScenarioScreens().map((screen) => screen.title), [ 'Login / Driver Verification', - 'Today’s Route', + 'Upcoming Routes', 'Route Details', + 'Route Session', 'Live Tracking', 'Stop Details', 'Arrival Check', @@ -90,6 +73,7 @@ describe('driver app MVP flow', () => { assert.deepEqual(getMvpRouteTabs(), [ { id: 'upcoming', label: 'Pending' }, { id: 'active', label: 'In Progress' }, + { id: 'unfinished', label: 'Unfinished' }, { id: 'completed', label: 'Completed' }, ]); }); @@ -97,9 +81,9 @@ describe('driver app MVP flow', () => { it('keeps proof and optional stop inputs aligned to the English arrival check design', () => { assert.deepEqual(getStopCompletionProofFields(), [ { id: 'photo', label: 'Photo Proof', required: true }, - { id: 'todayNote', label: 'Today’s Delivery Notes', required: false }, + { id: 'todayNote', label: 'Delivery Result', required: false }, { id: 'locationTip', label: 'Location Tip', required: false }, - { id: 'additionalNotes', label: 'Additional Notes', required: false }, + { id: 'additionalNotes', label: 'Other Notes', required: false }, ]); }); }); diff --git a/src/domain/driverFlow/driverFlow.ts b/src/domain/driverFlow/driverFlow.ts index e8bc1e1..ef92a4d 100644 --- a/src/domain/driverFlow/driverFlow.ts +++ b/src/domain/driverFlow/driverFlow.ts @@ -12,18 +12,6 @@ export const DRIVER_FLOW_STATES = [ export type DriverFlowState = (typeof DRIVER_FLOW_STATES)[number]; -export type InitialAccessValidationInput = { - routeContext?: string | null; - phoneE164: string; -}; - -export type InitialAccessValidationResult = - | { ok: true } - | { - ok: false; - reason: 'phone_required' | 'phone_invalid'; - }; - export type DeliveryActiveGuardInput = { state: DriverFlowState; hasLocationPermission: boolean; @@ -33,6 +21,7 @@ export type MvpScenarioScreenId = | 'login' | 'routeList' | 'routeDetail' + | 'routeSession' | 'liveTracking' | 'stopDetails' | 'arrivalCheck' @@ -47,8 +36,8 @@ export type MvpScenarioScreen = { }; export type MvpRouteTab = { - id: 'active' | 'completed' | 'upcoming'; - label: 'Completed' | 'In Progress' | 'Pending'; + id: 'active' | 'completed' | 'unfinished' | 'upcoming'; + label: 'Completed' | 'In Progress' | 'Pending' | 'Unfinished'; }; export type StopCompletionProofField = { @@ -64,22 +53,6 @@ const ROUTE_REVEAL_STATES = new Set([ 'delivery_finished', ]); -const E164_PHONE_PATTERN = /^\+[1-9]\d{7,14}$/; - -export function getInitialAccessValidation({ - phoneE164, -}: InitialAccessValidationInput): InitialAccessValidationResult { - if (phoneE164.trim().length === 0) { - return { ok: false, reason: 'phone_required' }; - } - - if (!E164_PHONE_PATTERN.test(phoneE164.trim())) { - return { ok: false, reason: 'phone_invalid' }; - } - - return { ok: true }; -} - export function canRevealRouteDetails(state: DriverFlowState): boolean { return ROUTE_REVEAL_STATES.has(state); } @@ -96,49 +69,65 @@ export function getMvpScenarioScreens(): MvpScenarioScreen[] { { id: 'login', title: 'Login / Driver Verification', - purpose: 'Confirm the driver by phone, then collect name and required consent.', + purpose: + 'Authenticate by phone and PIN, then collect required consent.', primaryAction: 'Continue', }, { id: 'routeList', - title: 'Today’s Route', - purpose: 'Show assigned routes grouped into Pending, In Progress, and Completed tabs.', - primaryAction: 'Start Route', + title: 'Upcoming Routes', + purpose: + 'Show current, unfinished, and future assigned routes from nearest date to farthest, grouped into Pending, In Progress, Unfinished, and Completed tabs.', + primaryAction: 'Start Session', }, + // routeDetail is metadata for the read-only preview entry; the live operational screen is routeSession. { id: 'routeDetail', title: 'Route Details', - purpose: 'Show company information, route date, region, and ordered stops before delivery starts.', - primaryAction: 'Begin Tracking', + purpose: + 'Show a compact read-only preview with date, map, region, stop count, distance, time, and sequence.', + primaryAction: 'Review Route', + }, + { + id: 'routeSession', + title: 'Route Session', + purpose: + 'Run the operational pickup, navigation, stop, proof, and completion workflow after Start or Continue Session.', + primaryAction: 'Continue Session', }, { id: 'liveTracking', title: 'Live Tracking', - purpose: 'Show GPS tracking status and route overview without turn-by-turn navigation.', + purpose: + 'Show GPS tracking status and route overview without turn-by-turn navigation.', primaryAction: 'Arrived', }, { id: 'stopDetails', title: 'Stop Details', - purpose: 'Show address, delivery instructions, location tips, and contact actions for the current stop.', + purpose: + 'Show address, delivery instructions, location tips, and contact actions for the current stop.', primaryAction: 'Arrived', }, { id: 'arrivalCheck', title: 'Arrival Check', - purpose: 'Collect required photo proof, delivery notes, location tips, and optional notes at the stop.', + purpose: + 'Collect required photo proof, delivery notes, location tips, and optional notes at the stop.', primaryAction: 'Complete Stop', }, { id: 'stopCompleted', title: 'Stop Completed', - purpose: 'Confirm stop completion and guide the driver to the next stop or route summary.', - primaryAction: 'Continue to Next Stop', + purpose: + 'Confirm stop completion and guide the driver to the next stop or route summary.', + primaryAction: 'Find Next Stop', }, { id: 'completedDeliveries', title: 'Completed Deliveries', - purpose: 'Show completed stops and proof status for the selected route or day.', + purpose: + 'Show completed stops and proof status for the selected route or day.', primaryAction: 'Back to Route', }, ]; @@ -148,6 +137,7 @@ export function getMvpRouteTabs(): MvpRouteTab[] { return [ { id: 'upcoming', label: 'Pending' }, { id: 'active', label: 'In Progress' }, + { id: 'unfinished', label: 'Unfinished' }, { id: 'completed', label: 'Completed' }, ]; } @@ -155,8 +145,8 @@ export function getMvpRouteTabs(): MvpRouteTab[] { export function getStopCompletionProofFields(): StopCompletionProofField[] { return [ { id: 'photo', label: 'Photo Proof', required: true }, - { id: 'todayNote', label: 'Today’s Delivery Notes', required: false }, + { id: 'todayNote', label: 'Delivery Result', required: false }, { id: 'locationTip', label: 'Location Tip', required: false }, - { id: 'additionalNotes', label: 'Additional Notes', required: false }, + { id: 'additionalNotes', label: 'Other Notes', required: false }, ]; } diff --git a/src/domain/notifications/stopArrivalNotifications.test.ts b/src/domain/notifications/stopArrivalNotifications.test.ts new file mode 100644 index 0000000..314bb64 --- /dev/null +++ b/src/domain/notifications/stopArrivalNotifications.test.ts @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { sampleAssignedRoute } from '../route/assignedRoute'; +import { + formatStopArrivalNotificationContent, + getStopArrivalNotificationCandidate, + parseStopArrivalNotificationData, + STOP_ARRIVAL_NOTIFICATION_TYPE, +} from './stopArrivalNotifications'; + +describe('stop arrival notifications', () => { + it('creates a stop-arrival notification candidate when the active stop is within the allowed radius', () => { + const firstStop = sampleAssignedRoute.stops[0]; + assert.notEqual(firstStop.coordinates, null); + + const candidate = getStopArrivalNotificationCandidate({ + completedStopIds: [], + currentStepIndex: 1, + isActiveRoute: true, + lastLocation: firstStop.coordinates, + notifiedStopIds: [], + radiusMeters: 50, + route: sampleAssignedRoute, + }); + + assert.equal(candidate?.data.type, STOP_ARRIVAL_NOTIFICATION_TYPE); + assert.equal(candidate?.data.routePlanId, sampleAssignedRoute.id); + assert.equal(candidate?.data.deliveryStopId, firstStop.deliveryStopId); + assert.equal(candidate?.stop.deliveryStopId, firstStop.deliveryStopId); + assert.deepEqual(candidate === null ? null : formatStopArrivalNotificationContent(candidate), { + body: 'You have arrived near the destination: 100 King St W, Toronto, ON, M5X 1A9.\nTomato box (Size: Large): 2', + title: 'Arrived near Stop 1', + }); + }); + + it('does not notify for company pickup, completed stops, repeated stops, or distant stops', () => { + const firstStop = sampleAssignedRoute.stops[0]; + assert.notEqual(firstStop.coordinates, null); + + const base = { + completedStopIds: [], + currentStepIndex: 1, + isActiveRoute: true, + lastLocation: firstStop.coordinates, + notifiedStopIds: [], + radiusMeters: 50, + route: sampleAssignedRoute, + }; + + assert.equal(getStopArrivalNotificationCandidate({ ...base, currentStepIndex: 0 }), null); + assert.equal(getStopArrivalNotificationCandidate({ ...base, completedStopIds: [firstStop.deliveryStopId] }), null); + assert.equal(getStopArrivalNotificationCandidate({ ...base, notifiedStopIds: [firstStop.deliveryStopId] }), null); + assert.equal(getStopArrivalNotificationCandidate({ ...base, lastLocation: { latitude: 0, longitude: 0 } }), null); + }); + + it('parses only the agreed server FCM stop-arrival payload', () => { + assert.deepEqual(parseStopArrivalNotificationData({ + deliveryStopId: 'stop-1', + routePlanId: 'route-1', + type: 'stop_arrival', + }), { + deliveryStopId: 'stop-1', + routePlanId: 'route-1', + type: STOP_ARRIVAL_NOTIFICATION_TYPE, + }); + + assert.equal(parseStopArrivalNotificationData({ deliveryStopId: 'stop-1', routePlanId: 'route-1' }), null); + assert.equal(parseStopArrivalNotificationData({ deliveryStopId: '', routePlanId: 'route-1', type: 'stop_arrival' }), null); + }); +}); diff --git a/src/domain/notifications/stopArrivalNotifications.ts b/src/domain/notifications/stopArrivalNotifications.ts new file mode 100644 index 0000000..05b2ce5 --- /dev/null +++ b/src/domain/notifications/stopArrivalNotifications.ts @@ -0,0 +1,164 @@ +import { + formatAssignedRouteItemLine, + type AssignedRoute, + type AssignedRouteCoordinates, + type AssignedRouteStop, +} from '../route/assignedRoute'; + +export const STOP_ARRIVAL_NOTIFICATION_TYPE = 'stop_arrival'; +export const DEFAULT_STOP_ARRIVAL_RADIUS_METERS = 50; + +export type StopArrivalNotificationData = { + deliveryStopId: string; + routePlanId: string; + type: typeof STOP_ARRIVAL_NOTIFICATION_TYPE; +}; + +export type StopArrivalNotificationCandidate = { + data: StopArrivalNotificationData; + distanceMeters: number; + radiusMeters: number; + stop: AssignedRouteStop; +}; + +export type StopArrivalNotificationContent = { + body: string; + title: string; +}; + +export type StopArrivalLocation = { + latitude: number; + longitude: number; +}; + +export type StopArrivalNotificationRegistrationResult = + | { + devicePushToken: string | null; + kind: 'registered'; + } + | { + kind: 'permission_denied' | 'unavailable'; + message: string; + }; + +export type StopArrivalNotificationService = { + addStopArrivalResponseListener(listener: (data: StopArrivalNotificationData) => void): () => void; + getLastStopArrivalResponse(): Promise; + registerForStopArrivalNotifications(): Promise; + scheduleStopArrivalNotification(input: StopArrivalNotificationCandidate): Promise; +}; + +export function getStopArrivalNotificationCandidate(input: { + completedStopIds: string[]; + currentStepIndex: number; + isActiveRoute: boolean; + lastLocation: StopArrivalLocation | null; + notifiedStopIds: string[]; + radiusMeters?: number; + route: AssignedRoute | null; +}): StopArrivalNotificationCandidate | null { + const radiusMeters = input.radiusMeters ?? DEFAULT_STOP_ARRIVAL_RADIUS_METERS; + if (!input.isActiveRoute || input.route === null || input.lastLocation === null || input.currentStepIndex <= 0) { + return null; + } + + const stop = input.route.stops[input.currentStepIndex - 1] ?? null; + if (stop === null || input.completedStopIds.includes(stop.deliveryStopId) || input.notifiedStopIds.includes(stop.deliveryStopId)) { + return null; + } + + const destination = resolveStopCoordinates(input.route, stop); + if (destination === null) { + return null; + } + + const distanceMeters = getDistanceMeters(input.lastLocation, destination); + if (distanceMeters > radiusMeters) { + return null; + } + + return { + data: { + deliveryStopId: stop.deliveryStopId, + routePlanId: input.route.id, + type: STOP_ARRIVAL_NOTIFICATION_TYPE, + }, + distanceMeters, + radiusMeters, + stop, + }; +} + +export function parseStopArrivalNotificationData(data: Record | null | undefined): StopArrivalNotificationData | null { + if (data === null || data === undefined) { + return null; + } + + if ( + data.type !== STOP_ARRIVAL_NOTIFICATION_TYPE || + typeof data.routePlanId !== 'string' || + data.routePlanId.trim().length === 0 || + typeof data.deliveryStopId !== 'string' || + data.deliveryStopId.trim().length === 0 + ) { + return null; + } + + return { + deliveryStopId: data.deliveryStopId, + routePlanId: data.routePlanId, + type: STOP_ARRIVAL_NOTIFICATION_TYPE, + }; +} + +export function formatStopArrivalNotificationContent(candidate: StopArrivalNotificationCandidate): StopArrivalNotificationContent { + const itemLines = candidate.stop.items.map(formatAssignedRouteItemLine); + return { + body: [ + `You have arrived near the destination: ${formatStopArrivalAddress(candidate.stop)}.`, + ...itemLines, + ].join('\n'), + title: `Arrived near Stop ${candidate.stop.sequence}`, + }; +} + +function resolveStopCoordinates(route: AssignedRoute, stop: AssignedRouteStop): AssignedRouteCoordinates | null { + if (stop.coordinates !== null) { + return stop.coordinates; + } + + const stopPoint = route.routeStopPoints.find((point) => point.deliveryStopId === stop.deliveryStopId); + if (stopPoint?.snappedCoordinates !== null && stopPoint?.snappedCoordinates !== undefined) { + const [longitude, latitude] = stopPoint.snappedCoordinates; + return { latitude, longitude }; + } + + return null; +} + +function formatStopArrivalAddress(stop: AssignedRouteStop): string { + return [ + stop.address.address1, + stop.address.address2, + stop.address.city, + stop.address.province, + stop.address.postalCode, + ] + .map((part) => part?.trim() ?? '') + .filter(Boolean) + .join(', '); +} + +function getDistanceMeters(a: StopArrivalLocation, b: StopArrivalLocation): number { + const earthRadiusMeters = 6_371_000; + const lat1 = toRadians(a.latitude); + const lat2 = toRadians(b.latitude); + const deltaLat = toRadians(b.latitude - a.latitude); + const deltaLng = toRadians(b.longitude - a.longitude); + const haversine = Math.sin(deltaLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLng / 2) ** 2; + return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)); +} + +function toRadians(degrees: number): number { + return degrees * Math.PI / 180; +} diff --git a/src/domain/offline/offlineSubmissionQueue.test.ts b/src/domain/offline/offlineSubmissionQueue.test.ts index a8a0d3c..4be0635 100644 --- a/src/domain/offline/offlineSubmissionQueue.test.ts +++ b/src/domain/offline/offlineSubmissionQueue.test.ts @@ -464,10 +464,16 @@ describe('offline submission queue', () => { assert.deepEqual(queue.listPending(), []); }); - it('discards route-scoped queued submissions when a route is completed', () => { + it('discards route-scoped non-terminal submissions when a route is completed', () => { const queue = createInMemoryOfflineSubmissionQueue(); queue.enqueueDriverEvent({ clientEventId: 'route-1-event', + eventType: 'LOCATION_UPDATED', + occurredAt: new Date('2026-05-12T11:00:00.000Z'), + routePlanId: 'route-1', + }); + queue.enqueueDriverEvent({ + clientEventId: 'route-1-terminal-stop', eventType: 'STOP_DELIVERED', occurredAt: new Date('2026-05-12T11:00:00.000Z'), routePlanId: 'route-1', @@ -481,7 +487,7 @@ describe('offline submission queue', () => { }); queue.enqueueDriverEvent({ clientEventId: 'route-2-event', - eventType: 'STOP_DELIVERED', + eventType: 'LOCATION_UPDATED', occurredAt: new Date('2026-05-12T11:00:00.000Z'), routePlanId: 'route-2', }); @@ -493,6 +499,7 @@ describe('offline submission queue', () => { assert.equal(queue.discardRouteSubmissions('route-1'), 2); assert.deepEqual(queue.listPending().map((item) => item.queueItemId), [ + 'driver-event:route-1-terminal-stop', 'driver-event:route-2-event', 'driver-event:unscoped-event', ]); diff --git a/src/domain/offline/offlineSubmissionQueue.ts b/src/domain/offline/offlineSubmissionQueue.ts index d5da7a2..d1d3ddd 100644 --- a/src/domain/offline/offlineSubmissionQueue.ts +++ b/src/domain/offline/offlineSubmissionQueue.ts @@ -93,6 +93,7 @@ export function createInMemoryOfflineSubmissionQueue(input?: { discardRouteSubmissions: (routePlanId) => { const queueItemIds = Array.from(items.values()) .filter((item) => getQueueItemRoutePlanId(item) === routePlanId) + .filter((item) => !isTerminalStopDriverEvent(item)) .map((item) => item.queueItemId); for (const queueItemId of queueItemIds) { items.delete(queueItemId); @@ -256,6 +257,12 @@ function getQueueItemRoutePlanId(item: OfflineSubmissionQueueItem): string | und return item.kind === 'driver_event' ? item.event.routePlanId ?? undefined : item.request.routePlanId; } +function isTerminalStopDriverEvent(item: OfflineSubmissionQueueItem): boolean { + return item.kind === 'driver_event' && ( + item.event.eventType === 'STOP_DELIVERED' || item.event.eventType === 'STOP_FAILED' + ); +} + function shouldDiscardOfflineSubmission( item: OfflineSubmissionQueueItem, retryPolicy: OfflineSubmissionQueueRetryPolicy, diff --git a/src/domain/phone/phoneEntry.ts b/src/domain/phone/phoneEntry.ts index 55965fc..f94b874 100644 --- a/src/domain/phone/phoneEntry.ts +++ b/src/domain/phone/phoneEntry.ts @@ -49,6 +49,9 @@ type DisplayNamesConstructor = new ( ) => { of(code: string): string | undefined }; type IntlLocaleWithWeekInfo = { + getWeekInfo?: () => { + firstDay?: number; + }; weekInfo?: { firstDay?: number; }; @@ -422,7 +425,8 @@ function getWeekStartsOn(locale: string): DriverWeekStartDay { } try { - const firstDay = new LOCALE(locale).weekInfo?.firstDay; + const localeInfo = new LOCALE(locale); + const firstDay = localeInfo.weekInfo?.firstDay ?? localeInfo.getWeekInfo?.().firstDay; if (typeof firstDay === 'number') { return WEEK_START_BY_FIRST_DAY[firstDay] ?? 'monday'; diff --git a/src/domain/proof/proofBarcodeCapture.test.ts b/src/domain/proof/proofBarcodeCapture.test.ts deleted file mode 100644 index d1cb504..0000000 --- a/src/domain/proof/proofBarcodeCapture.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; - -import { captureProofBarcode } from './proofBarcodeCapture'; - -describe('proof barcode capture', () => { - it('returns permission_denied before launching native scanner', async () => { - let launched = false; - const result = await captureProofBarcode({ - barcodeService: { - launchScanner: async () => { - launched = true; - return { data: 'SHOULD_NOT_SCAN', kind: 'scanned', symbology: 'qr' }; - }, - requestPermission: async () => 'denied', - }, - idFactory: () => 'barcode-1', - now: () => new Date('2026-05-12T10:10:00.000Z'), - }); - - assert.equal(launched, false); - assert.deepEqual(result, { - kind: 'permission_denied', - message: 'Camera permission is required to scan proof barcodes.', - }); - }); - - it('returns barcode evidence after native scanner reads a barcode', async () => { - const result = await captureProofBarcode({ - barcodeService: { - launchScanner: async () => ({ data: 'ORDER-1001', kind: 'scanned', symbology: 'code128' }), - requestPermission: async () => 'granted', - }, - idFactory: () => 'barcode-1', - now: () => new Date('2026-05-12T10:10:00.000Z'), - }); - - assert.deepEqual(result, { - barcode: { - barcodeId: 'barcode-1', - capturedAt: '2026-05-12T10:10:00.000Z', - data: 'ORDER-1001', - kind: 'barcode', - source: 'native-scanner', - symbology: 'code128', - }, - kind: 'scanned', - }); - }); -}); diff --git a/src/domain/proof/proofBarcodeCapture.ts b/src/domain/proof/proofBarcodeCapture.ts deleted file mode 100644 index dfe0af1..0000000 --- a/src/domain/proof/proofBarcodeCapture.ts +++ /dev/null @@ -1,65 +0,0 @@ -export type ProofBarcodePermissionResult = 'denied' | 'granted'; - -export type ProofBarcodeScanResult = - | { data: string; kind: 'scanned'; symbology: string } - | { kind: 'cancelled' } - | { kind: 'unavailable'; message: string }; - -export type ProofBarcodeReference = { - barcodeId: string; - capturedAt: string; - data: string; - kind: 'barcode'; - source: 'native-scanner'; - symbology: string; -}; - -export type ProofBarcodeCaptureResult = - | { barcode: ProofBarcodeReference; kind: 'scanned' } - | { kind: 'cancelled'; message: string } - | { kind: 'permission_denied'; message: string } - | { kind: 'unavailable'; message: string }; - -export type ProofBarcodeCaptureService = { - launchScanner(): Promise; - requestPermission(): Promise; -}; - -export async function captureProofBarcode(input: { - barcodeService: ProofBarcodeCaptureService; - idFactory?: () => string; - now?: () => Date; -}): Promise { - const permission = await input.barcodeService.requestPermission(); - if (permission !== 'granted') { - return { - kind: 'permission_denied', - message: 'Camera permission is required to scan proof barcodes.', - }; - } - - const result = await input.barcodeService.launchScanner(); - if (result.kind === 'cancelled') { - return { kind: 'cancelled', message: 'Barcode scan cancelled.' }; - } - - if (result.kind === 'unavailable') { - return result; - } - - return { - barcode: { - barcodeId: (input.idFactory ?? createBarcodeId)(), - capturedAt: (input.now ?? (() => new Date()))().toISOString(), - data: result.data, - kind: 'barcode', - source: 'native-scanner', - symbology: result.symbology, - }, - kind: 'scanned', - }; -} - -function createBarcodeId(): string { - return `barcode-${Date.now().toString(36)}`; -} diff --git a/src/domain/proof/proofMediaUpload.test.ts b/src/domain/proof/proofMediaUpload.test.ts index 0f34ad2..25e65f3 100644 --- a/src/domain/proof/proofMediaUpload.test.ts +++ b/src/domain/proof/proofMediaUpload.test.ts @@ -11,6 +11,69 @@ import { } from './proofMediaUpload'; describe('proof media upload', () => { + it('uses React Native XMLHttpRequest for live file uploads by default', async () => { + const requests: { body?: unknown; headers: Record; method?: string; timeout?: number; url?: string }[] = []; + class MockXMLHttpRequest { + onerror: (() => void) | null = null; + onload: (() => void) | null = null; + ontimeout: (() => void) | null = null; + responseText = JSON.stringify({ + data: { + contentType: 'image/jpeg', + kind: 'photo', + mediaId: 'media-xhr', + source: 'camera', + storageKey: 'driver-proof/media-xhr.jpg', + uploadedAt: '2026-05-12T10:00:00.000Z', + }, + }); + status = 201; + timeout = 0; + private readonly headers: Record = {}; + private method?: string; + private url?: string; + + open(method: string, url: string) { + this.method = method; + this.url = url; + } + + setRequestHeader(name: string, value: string) { + this.headers[name] = value; + } + + send(body: unknown) { + requests.push({ body, headers: this.headers, method: this.method, timeout: this.timeout, url: this.url }); + this.onload?.(); + } + } + + const service = createProofMediaUploadApiClient({ + accessToken: 'driver-token', + baseUrl: 'https://delivery.example.com/', + xmlHttpRequestFactory: () => new MockXMLHttpRequest() as unknown as XMLHttpRequest, + }); + + const result = await uploadCapturedProofPhoto({ + captureResult: { kind: 'captured', source: 'camera', uri: 'file:///proof/stop-1.jpg' }, + uploadRequest: { + deliveryStopId: 'stop-1', + fileName: 'stop-1.jpg', + routePlanId: 'route-1', + }, + uploadService: service, + }); + + assert.equal(result.kind, 'uploaded'); + assert.equal(requests[0]?.url, 'https://delivery.example.com/driver/proof-media'); + assert.equal(requests[0]?.method, 'POST'); + assert.equal(requests[0]?.timeout, 30000); + assert.equal(requests[0]?.headers.Authorization, 'Bearer driver-token'); + assert.equal(requests[0]?.headers['Cache-Control'], 'no-store'); + assert.equal(requests[0]?.headers.Pragma, 'no-cache'); + assert.ok(requests[0]?.body instanceof FormData); + }); + it('uploads captured proof photo with driver bearer token and returns durable media reference', async () => { const requests: { body: FormData; cache?: string; credentials?: string; headers: Record; method: string; url: string }[] = []; const service = createProofMediaUploadApiClient({ @@ -95,7 +158,7 @@ describe('proof media upload', () => { assert.deepEqual(result, { kind: 'skipped', - message: 'Proof photo was not captured, so no media upload was attempted.', + message: 'No photo selected.', reason: 'photo_not_captured', }); }); @@ -117,7 +180,66 @@ describe('proof media upload', () => { assert.deepEqual(result, { kind: 'upload_failed', - message: 'Proof media upload failed: network down', + message: 'Photo upload failed: network down', + }); + }); + + it('keeps proof media HTTP status when an error response is not JSON', async () => { + const service = createProofMediaUploadApiClient({ + accessToken: 'driver-token', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: false, + status: 413, + json: async () => { + throw new Error('HTML error body'); + }, + }), + }); + + const result = await uploadCapturedProofPhoto({ + captureResult: { kind: 'captured', source: 'camera', uri: 'file:///proof/stop-1.jpg' }, + uploadRequest: { + deliveryStopId: 'stop-1', + fileName: 'stop-1.jpg', + routePlanId: 'route-1', + }, + uploadService: service, + }); + + assert.deepEqual(result, { + kind: 'upload_failed', + message: 'Photo upload failed (HTTP 413). Try again.', + }); + }); + + it('shows proof media HTTP status for live upload failures', async () => { + const service = createProofMediaUploadApiClient({ + accessToken: 'driver-token', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: false, + status: 400, + json: async () => ({ + data: null, + error: { code: 'BAD_REQUEST', message: 'Invalid proof media upload payload' }, + }), + }), + }); + + const result = await uploadCapturedProofPhoto({ + captureResult: { kind: 'captured', source: 'camera', uri: 'file:///proof/stop-1.jpg' }, + uploadRequest: { + deliveryStopId: 'stop-1', + fileName: 'stop-1.jpg', + routePlanId: 'route-1', + }, + uploadService: service, + }); + + assert.deepEqual(result, { + kind: 'upload_failed', + message: 'Photo upload failed (HTTP 400). Try again.', }); }); @@ -147,7 +269,7 @@ describe('proof media upload', () => { assert.deepEqual(result, { kind: 'upload_failed', - message: 'Proof media upload failed: Driver session expired. Look up the route with route context and phone again. (HTTP 401)', + message: 'Session expired. Sign in again to sync this photo.', reason: 'driver_access_expired', }); }); @@ -178,7 +300,7 @@ describe('proof media upload', () => { assert.deepEqual(result, { kind: 'upload_failed', - message: 'Proof photo was rejected by the safety scan. Capture another proof photo.', + message: 'Photo could not be used. Take another photo.', reason: 'proof_media_rejected', }); assert.equal(shouldQueueFailedProofMediaUpload(result), false); @@ -188,14 +310,14 @@ describe('proof media upload', () => { assert.equal( shouldQueueFailedProofMediaUpload({ kind: 'upload_failed', - message: 'Proof media upload failed: network down', + message: 'Photo upload failed. Try again.', }), true, ); assert.equal( shouldQueueFailedProofMediaUpload({ kind: 'upload_failed', - message: 'Proof media upload failed: Driver session expired. Look up the route with route context and phone again. (HTTP 401)', + message: 'Session expired. Sign in again to sync this photo.', reason: 'driver_access_expired', }), true, @@ -203,7 +325,7 @@ describe('proof media upload', () => { assert.equal( shouldQueueFailedProofMediaUpload({ kind: 'upload_failed', - message: 'Proof photo was rejected by the safety scan. Capture another proof photo.', + message: 'Photo could not be used. Take another photo.', reason: 'proof_media_rejected', }), false, @@ -211,7 +333,7 @@ describe('proof media upload', () => { assert.equal( shouldQueueFailedProofMediaUpload({ kind: 'skipped', - message: 'Proof photo was not captured, so no media upload was attempted.', + message: 'No photo selected.', reason: 'photo_not_captured', }), false, @@ -219,7 +341,7 @@ describe('proof media upload', () => { }); it('can create a scanner rejection error for offline retry discard paths', () => { - assert.equal(createProofMediaRejectedError().message, 'Proof photo was rejected by the safety scan. Capture another proof photo.'); + assert.equal(createProofMediaRejectedError().message, 'Photo could not be used. Take another photo.'); }); it('can simulate scanner rejection through the local proof media mock mode', async () => { @@ -235,7 +357,7 @@ describe('proof media upload', () => { assert.deepEqual(result, { kind: 'upload_failed', - message: 'Proof photo was rejected by the safety scan. Capture another proof photo.', + message: 'Photo could not be used. Take another photo.', reason: 'proof_media_rejected', }); assert.equal(shouldQueueFailedProofMediaUpload(result), false); @@ -254,7 +376,7 @@ describe('proof media upload', () => { assert.deepEqual(result, { kind: 'upload_failed', - message: 'Proof media upload failed: Proof media mock upload failed', + message: 'Photo upload failed: Proof media mock upload failed', }); assert.equal(shouldQueueFailedProofMediaUpload(result), true); }); diff --git a/src/domain/proof/proofMediaUpload.ts b/src/domain/proof/proofMediaUpload.ts index f2865eb..05ba5d0 100644 --- a/src/domain/proof/proofMediaUpload.ts +++ b/src/domain/proof/proofMediaUpload.ts @@ -1,7 +1,7 @@ import type { ProofPhotoCaptureResult, ProofPhotoCaptureSource } from './proofPhotoCapture'; import { createDriverApiHttpError, - formatDriverApiErrorForDriver, + DriverApiHttpError, getDriverApiRecoveryReason, } from '../../api/deliveryServer/driverApiError'; import { withNoStoreDriverApiRequest } from '../../api/deliveryServer/driverApiRequestOptions'; @@ -39,7 +39,7 @@ export type ProofMediaUploadResult = | { kind: 'uploaded'; media: ProofMediaReference }; export const PROOF_MEDIA_REJECTED_MESSAGE = - 'Proof photo was rejected by the safety scan. Capture another proof photo.'; + 'Photo could not be used. Take another photo.'; export class ProofMediaRejectedError extends Error { constructor() { @@ -71,6 +71,12 @@ export type FetchLike = ( status?: number; }>; +type ProofMediaHttpResponse = { + json(): Promise; + ok: boolean; + status?: number; +}; + export function createMockProofMediaUploadService(input?: { mode?: ProofMediaUploadMockMode; }): ProofMediaUploadService { @@ -106,22 +112,32 @@ export function createProofMediaUploadApiClient(input: { accessToken: string; baseUrl: string; fetchImpl?: FetchLike; + xmlHttpRequestFactory?: () => XMLHttpRequest; }): ProofMediaUploadService { const baseUrl = input.baseUrl.replace(/\/$/u, ''); - const fetchImpl = input.fetchImpl ?? globalThis.fetch; return { uploadProofMedia: async (request) => { - const response = await fetchImpl(`${baseUrl}/driver/proof-media`, withNoStoreDriverApiRequest({ - body: toProofMediaFormData(request), - headers: { - Authorization: `Bearer ${input.accessToken}`, - }, - method: 'POST', - })); - const payload = await response.json(); + const url = `${baseUrl}/driver/proof-media`; + const body = toProofMediaFormData(request); + const response = input.fetchImpl === undefined + ? await postProofMediaFormDataWithXmlHttpRequest({ + accessToken: input.accessToken, + body, + url, + xmlHttpRequestFactory: input.xmlHttpRequestFactory, + }) + : await input.fetchImpl(url, withNoStoreDriverApiRequest({ + body, + headers: { + Authorization: `Bearer ${input.accessToken}`, + }, + method: 'POST', + })); + const payload = await readResponseJson(response); if (!response.ok) { - if (response.status === 422 && readDriverApiErrorCode(payload) === 'PROOF_MEDIA_REJECTED') { + const apiError = readDriverApiError(payload); + if (response.status === 422 && apiError.code === 'PROOF_MEDIA_REJECTED') { throw createProofMediaRejectedError(); } @@ -136,6 +152,34 @@ export function createProofMediaUploadApiClient(input: { }; } +function postProofMediaFormDataWithXmlHttpRequest(input: { + accessToken: string; + body: FormData; + url: string; + xmlHttpRequestFactory?: () => XMLHttpRequest; +}): Promise { + const createRequest = input.xmlHttpRequestFactory ?? (() => new XMLHttpRequest()); + + return new Promise((resolve, reject) => { + const request = createRequest(); + request.open('POST', input.url); + request.timeout = 30000; + request.setRequestHeader('Authorization', `Bearer ${input.accessToken}`); + request.setRequestHeader('Cache-Control', 'no-store'); + request.setRequestHeader('Pragma', 'no-cache'); + request.onload = () => { + resolve({ + ok: request.status >= 200 && request.status < 300, + status: request.status, + json: async () => parseJsonOrNull(request.responseText), + }); + }; + request.onerror = () => reject(new Error('Network request failed')); + request.ontimeout = () => reject(new Error('Network request timed out')); + request.send(input.body); + }); +} + export async function uploadCapturedProofPhoto(input: { captureResult: ProofPhotoCaptureResult; uploadRequest: Omit; @@ -144,7 +188,7 @@ export async function uploadCapturedProofPhoto(input: { if (input.captureResult.kind !== 'captured') { return { kind: 'skipped', - message: 'Proof photo was not captured, so no media upload was attempted.', + message: 'No photo selected.', reason: 'photo_not_captured', }; } @@ -169,12 +213,29 @@ export async function uploadCapturedProofPhoto(input: { return { kind: 'upload_failed', - message: `Proof media upload failed: ${formatDriverApiErrorForDriver(error)}`, + message: formatProofMediaUploadFailure(error), ...(recoveryReason === undefined ? {} : { reason: recoveryReason }), }; } } +function formatProofMediaUploadFailure(error: unknown): string { + const recoveryReason = getDriverApiRecoveryReason(error); + if (recoveryReason === 'driver_access_expired') { + return 'Session expired. Sign in again to sync this photo.'; + } + + if (error instanceof DriverApiHttpError) { + return `Photo upload failed (HTTP ${error.status}). Try again.`; + } + + if (error instanceof Error && error.message.trim() !== '') { + return `Photo upload failed: ${error.message}`; + } + + return 'Photo upload failed. Try again.'; +} + export function shouldQueueFailedProofMediaUpload(result: ProofMediaUploadResult): boolean { return result.kind === 'upload_failed' && result.reason !== 'proof_media_rejected'; } @@ -214,18 +275,36 @@ function readProofMediaReferenceEnvelope(payload: unknown): ProofMediaReference return data; } -function readDriverApiErrorCode(payload: unknown): string | null { - if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { +async function readResponseJson(response: { json(): Promise }): Promise { + try { + return await response.json(); + } catch { return null; } +} + +function parseJsonOrNull(value: string): unknown { + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function readDriverApiError(payload: unknown): { code?: string } { + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + return {}; + } const error = (payload as { error?: unknown }).error; if (typeof error !== 'object' || error === null || Array.isArray(error)) { - return null; + return {}; } const code = (error as { code?: unknown }).code; - return typeof code === 'string' ? code : null; + return { + ...(typeof code === 'string' ? { code } : {}), + }; } function isProofMediaReference(value: unknown): value is ProofMediaReference { diff --git a/src/domain/proof/proofPhotoCapture.test.ts b/src/domain/proof/proofPhotoCapture.test.ts index 21759be..b3a69a1 100644 --- a/src/domain/proof/proofPhotoCapture.test.ts +++ b/src/domain/proof/proofPhotoCapture.test.ts @@ -15,7 +15,7 @@ describe('proof photo capture', () => { assert.deepEqual(result, { kind: 'permission_denied', - message: 'Photo permission is required to attach proof media.', + message: 'Allow photo access to add a delivery photo.', source: 'camera', }); }); diff --git a/src/domain/proof/proofPhotoCapture.ts b/src/domain/proof/proofPhotoCapture.ts index 8f38f63..9f53398 100644 --- a/src/domain/proof/proofPhotoCapture.ts +++ b/src/domain/proof/proofPhotoCapture.ts @@ -24,7 +24,7 @@ export async function captureProofPhoto(input: { if (permission !== 'granted') { return { kind: 'permission_denied', - message: 'Photo permission is required to attach proof media.', + message: 'Allow photo access to add a delivery photo.', source: input.source, }; } diff --git a/src/domain/route/assignedRoute.test.ts b/src/domain/route/assignedRoute.test.ts index 5c1eb19..9283d4d 100644 --- a/src/domain/route/assignedRoute.test.ts +++ b/src/domain/route/assignedRoute.test.ts @@ -4,9 +4,16 @@ import { describe, it } from 'node:test'; import { createAssignedRouteApiClient, createMockAssignedRouteService, + formatAssignedRouteDistance, + formatAssignedRouteDuration, + formatAssignedRouteItemLine, + hasAssignedRouteGeometry, + formatAssignedRoutePaymentStatus, loadAssignedRouteAfterConsent, + resolveRouteMapPreviewState, sampleAssignedRoute, } from './assignedRoute'; +import { assignedRouteValidationScenarios } from './assignedRouteValidationScenarios'; describe('driver assigned route UX flow', () => { it('blocks route reads before consent is recorded', async () => { @@ -45,10 +52,19 @@ describe('driver assigned route UX flow', () => { assert.equal(result.flowState, 'route_ready'); assert.equal(result.route.name, 'Tuesday AM Route'); assert.equal(result.route.stops.length, 2); + assert.equal(formatAssignedRouteDistance(result.route.routeMetrics), '3.3 km'); + assert.equal(formatAssignedRouteDuration(result.route.routeMetrics), '14 min'); + assert.equal(hasAssignedRouteGeometry(result.route), true); assert.deepEqual( result.route.stops.map((stop) => stop.sequence), [1, 2], ); + assert.deepEqual( + result.route.stops.map((stop) => stop.normalizedPaymentStatus), + ['CASH_COLLECT_REQUIRED', 'TRANSFER_CHECK_PENDING'], + ); + assert.equal(result.route.stops[0]?.items[0]?.name, 'Tomato box'); + assert.equal(formatAssignedRouteItemLine(result.route.stops[0]!.items[0]!), 'Tomato box (Size: Large): 2'); assert.equal(JSON.stringify(result).includes('tomatono.myshopify.com'), true); }); @@ -64,7 +80,7 @@ describe('driver assigned route UX flow', () => { assert.deepEqual(result, { flowState: 'consent_recorded', kind: 'no_assigned_route', - message: 'No assigned route is available for this driver and route context today.', + message: 'No current or upcoming route is available for this driver and route context.', }); }); @@ -146,6 +162,7 @@ describe('driver assigned route UX flow', () => { }); assert.equal(result.status, 'ASSIGNED_ROUTE'); + assert.equal(result.route.routeMetrics?.distanceMeters, 3250); assert.deepEqual(requests, [ { cache: 'no-store', @@ -160,4 +177,338 @@ describe('driver assigned route UX flow', () => { }, ]); }); + + + it('accepts assigned routes when the server omits shop timezone', async () => { + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { + status: 'ASSIGNED_ROUTE', + route: { + ...sampleAssignedRoute, + timezone: null, + }, + }, + error: null, + }), + }), + }); + + const result = await client.getAssignedRoute({ routeContext: sampleAssignedRoute.id }); + + assert.equal(result.status, 'ASSIGNED_ROUTE'); + assert.equal(result.route.timezone, 'America/Toronto'); + }); + + it('rejects assigned route payloads that omit required stop item arrays', async () => { + const routePayload = { + ...sampleAssignedRoute, + stops: sampleAssignedRoute.stops.map(({ items: _items, ...stop }) => stop), + }; + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { status: 'ASSIGNED_ROUTE', route: routePayload }, + error: null, + }), + }), + }); + + await assert.rejects( + () => client.getAssignedRoute({ routeContext: sampleAssignedRoute.id }), + /Invalid assigned route response/u, + ); + }); + + + it('accepts additive OSRM route geometry, metrics, and stop snap points from assigned-route responses', async () => { + const firstStop = sampleAssignedRoute.stops[0]; + assert.ok(firstStop); + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { + status: 'ASSIGNED_ROUTE', + route: { + ...sampleAssignedRoute, + routeGeometry: { + coordinates: [ + [-79.3832, 43.6532], + [-79.3817, 43.6487], + ], + type: 'LineString', + }, + routeMetrics: { + distanceMeters: 980.5, + durationSeconds: 420, + }, + routeStopPoints: [ + { + deliveryStopId: firstStop.deliveryStopId, + inputCoordinates: [-79.3817, 43.6487], + name: 'King Street West', + sequence: 1, + snapDistanceMeters: 3.5, + snappedCoordinates: [-79.3818, 43.6488], + }, + ], + }, + }, + error: null, + }), + }), + }); + + const result = await client.getAssignedRoute({ routeContext: sampleAssignedRoute.id }); + + assert.equal(result.status, 'ASSIGNED_ROUTE'); + assert.deepEqual(result.route.routeMetrics, { distanceMeters: 980.5, durationSeconds: 420 }); + assert.equal(formatAssignedRouteDistance(result.route.routeMetrics), '981 m'); + assert.equal(formatAssignedRouteDuration(result.route.routeMetrics), '7 min'); + assert.equal(result.route.routeGeometry?.coordinates.length, 2); + assert.equal(result.route.routeStopPoints[0]?.name, 'King Street West'); + }); + + it('accepts static route map preview metadata from assigned-route responses', async () => { + const preview = { + altText: 'Static route preview for 2 stops.', + contentType: 'image/png', + expiresAt: '2026-05-12T07:00:00.000Z', + generatedAt: '2026-05-12T06:50:00.000Z', + height: 430, + imageUrl: 'https://delivery.example.com/driver/route-map-preview/opaque?expires=1781142000000&signature=preview', + kind: 'static_route_map', + routeSequenceChecksum: 'sample-route-checksum', + width: 720, + }; + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { + status: 'ASSIGNED_ROUTE', + route: { + ...sampleAssignedRoute, + routeMapPreview: preview, + }, + }, + error: null, + }), + }), + }); + + const result = await client.getAssignedRoute({ routeContext: sampleAssignedRoute.id }); + + assert.equal(result.status, 'ASSIGNED_ROUTE'); + assert.deepEqual(result.route.routeMapPreview, preview); + assert.deepEqual( + resolveRouteMapPreviewState({ + loadStatus: 'idle', + now: new Date('2026-05-12T06:55:00.000Z'), + preview: result.route.routeMapPreview, + }), + { + accessibilityLabel: preview.altText, + imageUrl: preview.imageUrl, + kind: 'available', + }, + ); + }); + + it('defines tab-level synthetic validation scenarios with safe coordinates and OSRM evidence expectations', () => { + assert.deepEqual( + assignedRouteValidationScenarios.map((scenario) => scenario.tab), + ['upcoming', 'active', 'completed'], + ); + + for (const scenario of assignedRouteValidationScenarios) { + assert.equal(scenario.route.name.startsWith('[TEST]'), true); + assert.equal(scenario.route.shopDomain, 'validation-only.example.test'); + assert.equal(scenario.expectedEvidence.safeForOperationalSmoke, true); + assert.equal( + scenario.route.stops.every((stop) => stop.coordinates !== null && stop.orderName.includes('TEST-OSRM')), + scenario.expectedEvidence.hasCoordinatesForEveryStop, + ); + assert.equal(hasAssignedRouteGeometry(scenario.route), scenario.expectedEvidence.hasOsrmGeometry); + assert.equal(scenario.route.routeMetrics !== null, scenario.expectedEvidence.hasOsrmMetrics); + } + }); + + it('keeps legacy assigned-route responses compatible by defaulting OSRM fields to unavailable', async () => { + const { + routeGeometry: _routeGeometry, + routeMetrics: _routeMetrics, + routeStopPoints: _routeStopPoints, + routeMapPreview: _routeMapPreview, + ...legacyRoute + } = sampleAssignedRoute; + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { status: 'ASSIGNED_ROUTE', route: legacyRoute }, + error: null, + }), + }), + }); + + const result = await client.getAssignedRoute({ routeContext: sampleAssignedRoute.id }); + + assert.equal(result.status, 'ASSIGNED_ROUTE'); + assert.equal(result.route.routeGeometry, null); + assert.equal(result.route.routeMapPreview, null); + assert.equal(result.route.routeMetrics, null); + assert.deepEqual(result.route.routeStopPoints, []); + assert.equal(formatAssignedRouteDistance(result.route.routeMetrics), 'Not available'); + assert.equal(formatAssignedRouteDuration(result.route.routeMetrics), 'Not available'); + }); + + it('maps route map preview helper states for missing, expired, and failed images', () => { + assert.deepEqual( + resolveRouteMapPreviewState({ + loadStatus: 'idle', + now: new Date('2026-05-12T06:55:00.000Z'), + preview: null, + }), + { + kind: 'missing', + message: 'Route preview unavailable. You can still open navigation for each stop.', + }, + ); + + assert.deepEqual( + resolveRouteMapPreviewState({ + loadStatus: 'idle', + now: new Date('2026-05-12T07:01:00.000Z'), + preview: sampleAssignedRoute.routeMapPreview, + }), + { + kind: 'expired', + message: 'Map preview couldn’t load. Route details are still available.', + }, + ); + + assert.deepEqual( + resolveRouteMapPreviewState({ + loadStatus: 'failed', + now: new Date('2026-05-12T06:55:00.000Z'), + preview: sampleAssignedRoute.routeMapPreview, + }), + { + kind: 'failed', + message: 'Map preview couldn’t load. Route details are still available.', + }, + ); + }); + + it('rejects malformed route map preview metadata from the assigned-route contract', async () => { + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { + status: 'ASSIGNED_ROUTE', + route: { + ...sampleAssignedRoute, + routeMapPreview: { + ...sampleAssignedRoute.routeMapPreview, + contentType: 'image/svg+xml', + }, + }, + }, + error: null, + }), + }), + }); + + await assert.rejects( + () => client.getAssignedRoute({ routeContext: 'route-id' }), + /Invalid assigned route response/u, + ); + }); + + it('preserves canonical normalizedPaymentStatus even when legacy payment fields conflict', async () => { + const routePayload = { + ...sampleAssignedRoute, + stops: [ + { + ...sampleAssignedRoute.stops[0], + financialStatus: 'Cash', + normalizedPaymentStatus: 'PAID_CONFIRMED', + paymentStatus: 'CASH_COLLECT_REQUIRED', + }, + ], + }; + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { status: 'ASSIGNED_ROUTE', route: routePayload }, + error: null, + }), + }), + }); + + const result = await client.getAssignedRoute({ routeContext: 'route-id' }); + + assert.equal(result.status, 'ASSIGNED_ROUTE'); + assert.equal(result.route.stops[0]?.normalizedPaymentStatus, 'PAID_CONFIRMED'); + }); + + it('rejects malformed normalized payment statuses from the assigned-route contract', async () => { + const routePayload = { + ...sampleAssignedRoute, + stops: [ + { + ...sampleAssignedRoute.stops[0], + normalizedPaymentStatus: 'cash', + }, + ], + }; + const client = createAssignedRouteApiClient({ + accessToken: 'driver.jwt', + baseUrl: 'https://delivery.example.com/', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { status: 'ASSIGNED_ROUTE', route: routePayload }, + error: null, + }), + }), + }); + + await assert.rejects( + () => client.getAssignedRoute({ routeContext: 'route-id' }), + /Invalid assigned route response/u, + ); + }); + + it('formats all normalized payment states into visible driver copy', () => { + assert.deepEqual(formatAssignedRoutePaymentStatus('PAID_CONFIRMED'), { + detail: 'Payment is confirmed in WooCommerce. Do not request payment at delivery.', + label: 'Paid confirmed', + tone: 'green', + }); + assert.equal(formatAssignedRoutePaymentStatus('UNKNOWN_REVIEW').label, 'Review payment'); + assert.equal(formatAssignedRoutePaymentStatus('NOT_DELIVERABLE_OR_EXCEPTION').label, 'Payment exception'); + assert.equal(formatAssignedRoutePaymentStatus(null).label, 'Payment unavailable'); + }); }); diff --git a/src/domain/route/assignedRoute.ts b/src/domain/route/assignedRoute.ts index e8dbdc8..031929d 100644 --- a/src/domain/route/assignedRoute.ts +++ b/src/domain/route/assignedRoute.ts @@ -20,10 +20,80 @@ export type AssignedRouteCoordinates = { longitude: number; }; +export type AssignedRouteLngLat = [number, number]; + +export type AssignedRouteGeometry = { + coordinates: AssignedRouteLngLat[]; + type: 'LineString'; +}; + +export type AssignedRouteMetrics = { + distanceMeters: number | null; + durationSeconds: number | null; +}; + +export type AssignedRouteMapPreview = { + altText: string; + contentType: 'image/png'; + expiresAt: string; + generatedAt: string; + height: number; + imageUrl: string; + kind: 'static_route_map'; + routeSequenceChecksum: string; + width: number; +}; + +export type RouteMapPreviewLoadStatus = 'idle' | 'failed'; + +export type RouteMapPreviewState = + | { + accessibilityLabel: string; + imageUrl: string; + kind: 'available'; + } + | { + kind: 'expired' | 'failed' | 'missing'; + message: string; + }; + +export const NORMALIZED_PAYMENT_STATUSES = [ + 'PAID_CONFIRMED', + 'CASH_COLLECT_REQUIRED', + 'TRANSFER_CHECK_PENDING', + 'ONLINE_PAYMENT_PENDING_OR_FAILED', + 'NOT_DELIVERABLE_OR_EXCEPTION', + 'UNKNOWN_REVIEW', +] as const; + +export type NormalizedPaymentStatus = (typeof NORMALIZED_PAYMENT_STATUSES)[number]; + +export type AssignedRouteOrderItemOption = { + key: string; + value: string; +}; + +export type AssignedRouteOrderItem = { + name: string; + options: AssignedRouteOrderItemOption[]; + productId: number; + quantity: number; + sku: string | null; + variationId: number; +}; + +export type AssignedRoutePaymentCopy = { + detail: string; + label: string; + tone: 'green' | 'neutral' | 'warning'; +}; + export type AssignedRouteStop = { address: AssignedRouteAddress; coordinates: AssignedRouteCoordinates | null; deliveryStopId: string; + items: AssignedRouteOrderItem[]; + normalizedPaymentStatus: NormalizedPaymentStatus | null; orderName: string; phone: string | null; recipientName: string | null; @@ -31,10 +101,25 @@ export type AssignedRouteStop = { status: string; }; +export type AssignedRouteStopPoint = { + deliveryStopId: string; + inputCoordinates: AssignedRouteLngLat | null; + name: string | null; + sequence: number; + snapDistanceMeters: number | null; + snappedCoordinates: AssignedRouteLngLat | null; +}; + +const DEFAULT_ASSIGNED_ROUTE_TIMEZONE = 'America/Toronto'; + export type AssignedRoute = { deliveryDate: string; id: string; name: string; + routeGeometry: AssignedRouteGeometry | null; + routeMapPreview: AssignedRouteMapPreview | null; + routeMetrics: AssignedRouteMetrics | null; + routeStopPoints: AssignedRouteStopPoint[]; shopDomain: string; stops: AssignedRouteStop[]; timezone: string; @@ -103,6 +188,47 @@ export const sampleAssignedRoute: AssignedRoute = { deliveryDate: '2026-05-12', id: '11111111-1111-4111-8111-111111111111', name: 'Tuesday AM Route', + routeGeometry: { + coordinates: [ + [-79.3832, 43.6532], + [-79.3817, 43.6487], + [-79.3909, 43.6509], + ], + type: 'LineString', + }, + routeMapPreview: { + altText: 'Static route preview for 2 stops.', + contentType: 'image/png', + expiresAt: '2026-05-12T07:00:00.000Z', + generatedAt: '2026-05-12T06:50:00.000Z', + height: 430, + imageUrl: 'https://delivery.example.com/driver/route-map-preview/opaque?expires=1781142000000&signature=preview', + kind: 'static_route_map', + routeSequenceChecksum: 'sample-route-checksum', + width: 720, + }, + routeMetrics: { + distanceMeters: 3250, + durationSeconds: 840, + }, + routeStopPoints: [ + { + deliveryStopId: '22222222-2222-4222-8222-222222222222', + inputCoordinates: [-79.3817, 43.6487], + name: 'King Street West', + sequence: 1, + snapDistanceMeters: 3.5, + snappedCoordinates: [-79.3818, 43.6488], + }, + { + deliveryStopId: '33333333-3333-4333-8333-333333333333', + inputCoordinates: [-79.3909, 43.6509], + name: 'Queen Street West', + sequence: 2, + snapDistanceMeters: 8.2, + snappedCoordinates: [-79.391, 43.651], + }, + ], shopDomain: 'tomatono.myshopify.com', stops: [ { @@ -119,6 +245,17 @@ export const sampleAssignedRoute: AssignedRoute = { longitude: -79.3817, }, deliveryStopId: '22222222-2222-4222-8222-222222222222', + items: [ + { + name: 'Tomato box', + options: [{ key: 'Size', value: 'Large' }], + productId: 101, + quantity: 2, + sku: 'TOM-L', + variationId: 7, + }, + ], + normalizedPaymentStatus: 'CASH_COLLECT_REQUIRED', orderName: '#1001', phone: '+14165550123', recipientName: 'Recipient One', @@ -139,6 +276,17 @@ export const sampleAssignedRoute: AssignedRoute = { longitude: -79.3909, }, deliveryStopId: '33333333-3333-4333-8333-333333333333', + items: [ + { + name: 'Basil bunch', + options: [], + productId: 202, + quantity: 1, + sku: null, + variationId: 0, + }, + ], + normalizedPaymentStatus: 'TRANSFER_CHECK_PENDING', orderName: '#1002', phone: '+14165550124', recipientName: 'Recipient Two', @@ -167,7 +315,7 @@ export async function loadAssignedRouteAfterConsent( return { flowState: 'consent_recorded', kind: 'no_assigned_route', - message: 'No assigned route is available for this driver and route context today.', + message: 'No current or upcoming route is available for this driver and route context.', }; } @@ -176,7 +324,9 @@ export async function loadAssignedRouteAfterConsent( kind: 'route_ready', route: { ...result.route, - stops: [...result.route.stops].sort((left, right) => left.sequence - right.sequence), + stops: [...result.route.stops] + .map(normalizeAssignedRouteStop) + .sort((left, right) => left.sequence - right.sequence), }, }; } catch (error) { @@ -237,30 +387,111 @@ export function createAssignedRouteApiClient(input: { }; } +export function formatAssignedRouteDistance(metrics: AssignedRouteMetrics | null): string { + const distanceMeters = metrics?.distanceMeters; + if (typeof distanceMeters !== 'number' || !Number.isFinite(distanceMeters) || distanceMeters < 0) { + return 'Not available'; + } + + if (distanceMeters < 1000) { + return `${Math.round(distanceMeters)} m`; + } + + return `${(distanceMeters / 1000).toFixed(1)} km`; +} + +export function formatAssignedRouteDuration(metrics: AssignedRouteMetrics | null): string { + const durationSeconds = metrics?.durationSeconds; + if (typeof durationSeconds !== 'number' || !Number.isFinite(durationSeconds) || durationSeconds < 0) { + return 'Not available'; + } + + const minutes = Math.max(1, Math.round(durationSeconds / 60)); + if (minutes < 60) { + return `${minutes} min`; + } + + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes === 0 ? `${hours} hr` : `${hours} hr ${remainingMinutes} min`; +} + +export function hasAssignedRouteGeometry(route: AssignedRoute): boolean { + return route.routeGeometry !== null && route.routeGeometry.coordinates.length >= 2; +} + +export function formatAssignedRouteItemOptions(item: Pick): string { + return item.options.map((option) => `${option.key}: ${option.value}`).join(' · '); +} + +export function formatAssignedRouteItemLine(item: AssignedRouteOrderItem): string { + const options = formatAssignedRouteItemOptions(item); + return `${item.name}${options.length === 0 ? '' : ` (${options})`}: ${item.quantity}`; +} + +export function resolveRouteMapPreviewState(input: { + loadStatus: RouteMapPreviewLoadStatus; + now: Date; + preview: AssignedRouteMapPreview | null; +}): RouteMapPreviewState { + if (input.preview === null) { + return { + kind: 'missing', + message: 'Route preview unavailable. You can still open navigation for each stop.', + }; + } + + if (input.loadStatus === 'failed') { + return { + kind: 'failed', + message: 'Map preview couldn’t load. Route details are still available.', + }; + } + + const expiresAt = Date.parse(input.preview.expiresAt); + if (!Number.isFinite(expiresAt) || expiresAt <= input.now.getTime()) { + return { + kind: 'expired', + message: 'Map preview couldn’t load. Route details are still available.', + }; + } + + return { + accessibilityLabel: input.preview.altText, + imageUrl: input.preview.imageUrl, + kind: 'available', + }; +} + function readAssignedRouteEnvelope(payload: unknown): AssignedRouteLookupResult { if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { throw new Error('Invalid assigned route response'); } const data = (payload as { data?: unknown }).data; - if (!isAssignedRouteLookupResult(data)) { + const result = readAssignedRouteLookupResult(data); + if (result === null) { throw new Error('Invalid assigned route response'); } - return data; + return result; } -function isAssignedRouteLookupResult(value: unknown): value is AssignedRouteLookupResult { +function readAssignedRouteLookupResult(value: unknown): AssignedRouteLookupResult | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return false; + return null; } const result = value as Record; if (result.status === 'NO_ASSIGNED_ROUTE') { - return true; + return { status: 'NO_ASSIGNED_ROUTE' }; } - return result.status === 'ASSIGNED_ROUTE' && isAssignedRoute(result.route); + if (result.status === 'ASSIGNED_ROUTE' && isAssignedRoute(result.route)) { + return { status: 'ASSIGNED_ROUTE', route: normalizeAssignedRoute(result.route) }; + } + + return null; } function isAssignedRoute(value: unknown): value is AssignedRoute { @@ -273,10 +504,100 @@ function isAssignedRoute(value: unknown): value is AssignedRoute { typeof route.deliveryDate === 'string' && typeof route.id === 'string' && typeof route.name === 'string' && + (route.routeGeometry === undefined || route.routeGeometry === null || isAssignedRouteGeometry(route.routeGeometry)) && + (route.routeMapPreview === undefined || route.routeMapPreview === null || isAssignedRouteMapPreview(route.routeMapPreview)) && + (route.routeMetrics === undefined || route.routeMetrics === null || isAssignedRouteMetrics(route.routeMetrics)) && + (route.routeStopPoints === undefined || (Array.isArray(route.routeStopPoints) && route.routeStopPoints.every(isAssignedRouteStopPoint))) && typeof route.shopDomain === 'string' && Array.isArray(route.stops) && route.stops.every(isAssignedRouteStop) && - typeof route.timezone === 'string' + nullableString(route.timezone) + ); +} + +function normalizeAssignedRoute(route: AssignedRoute): AssignedRoute { + return { + ...route, + routeGeometry: route.routeGeometry ?? null, + routeMapPreview: route.routeMapPreview ?? null, + routeMetrics: route.routeMetrics ?? null, + routeStopPoints: route.routeStopPoints ?? [], + stops: route.stops.map(normalizeAssignedRouteStop), + timezone: route.timezone ?? DEFAULT_ASSIGNED_ROUTE_TIMEZONE, + }; +} + +function normalizeAssignedRouteStop(stop: AssignedRouteStop): AssignedRouteStop { + return { + ...stop, + items: stop.items.map((item) => ({ + ...item, + options: [...item.options], + })), + }; +} + +function isAssignedRouteMapPreview(value: unknown): value is AssignedRouteMapPreview { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const preview = value as Record; + return ( + preview.kind === 'static_route_map' && + typeof preview.imageUrl === 'string' && + preview.imageUrl.trim().length > 0 && + typeof preview.width === 'number' && + Number.isFinite(preview.width) && + preview.width > 0 && + typeof preview.height === 'number' && + Number.isFinite(preview.height) && + preview.height > 0 && + preview.contentType === 'image/png' && + typeof preview.generatedAt === 'string' && + typeof preview.expiresAt === 'string' && + typeof preview.routeSequenceChecksum === 'string' && + typeof preview.altText === 'string' && + preview.altText.trim().length > 0 + ); +} + +function isAssignedRouteGeometry(value: unknown): value is AssignedRouteGeometry { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const geometry = value as Record; + return ( + geometry.type === 'LineString' && + Array.isArray(geometry.coordinates) && + geometry.coordinates.length >= 2 && + geometry.coordinates.every(isAssignedRouteLngLat) + ); +} + +function isAssignedRouteMetrics(value: unknown): value is AssignedRouteMetrics { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const metrics = value as Record; + return nullableFiniteNumber(metrics.distanceMeters) && nullableFiniteNumber(metrics.durationSeconds); +} + +function isAssignedRouteStopPoint(value: unknown): value is AssignedRouteStopPoint { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const stopPoint = value as Record; + return ( + typeof stopPoint.deliveryStopId === 'string' && + (stopPoint.inputCoordinates === null || isAssignedRouteLngLat(stopPoint.inputCoordinates)) && + nullableString(stopPoint.name) && + typeof stopPoint.sequence === 'number' && + nullableFiniteNumber(stopPoint.snapDistanceMeters) && + (stopPoint.snappedCoordinates === null || isAssignedRouteLngLat(stopPoint.snappedCoordinates)) ); } @@ -290,6 +611,9 @@ function isAssignedRouteStop(value: unknown): value is AssignedRouteStop { isAssignedRouteAddress(stop.address) && (stop.coordinates === null || isAssignedRouteCoordinates(stop.coordinates)) && typeof stop.deliveryStopId === 'string' && + Array.isArray(stop.items) && + stop.items.every(isAssignedRouteOrderItem) && + isNormalizedPaymentStatus(stop.normalizedPaymentStatus) && typeof stop.orderName === 'string' && nullableString(stop.phone) && nullableString(stop.recipientName) && @@ -298,6 +622,40 @@ function isAssignedRouteStop(value: unknown): value is AssignedRouteStop { ); } +function isAssignedRouteOrderItem(value: unknown): value is AssignedRouteOrderItem { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const item = value as Record; + return ( + typeof item.name === 'string' && + Array.isArray(item.options) && + item.options.every(isAssignedRouteOrderItemOption) && + typeof item.productId === 'number' && + Number.isFinite(item.productId) && + typeof item.quantity === 'number' && + Number.isFinite(item.quantity) && + item.quantity > 0 && + nullableString(item.sku) && + typeof item.variationId === 'number' && + Number.isFinite(item.variationId) + ); +} + +function isAssignedRouteOrderItemOption(value: unknown): value is AssignedRouteOrderItemOption { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + + const option = value as Record; + return typeof option.key === 'string' && typeof option.value === 'string'; +} + +function isNormalizedPaymentStatus(value: unknown): value is NormalizedPaymentStatus | null { + return value === null || NORMALIZED_PAYMENT_STATUSES.some((status) => status === value); +} + function isAssignedRouteAddress(value: unknown): value is AssignedRouteAddress { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; @@ -323,6 +681,78 @@ function isAssignedRouteCoordinates(value: unknown): value is AssignedRouteCoord return typeof coordinates.latitude === 'number' && typeof coordinates.longitude === 'number'; } +function isAssignedRouteLngLat(value: unknown): value is AssignedRouteLngLat { + if (!Array.isArray(value) || value.length !== 2) { + return false; + } + + const longitude = value[0]; + const latitude = value[1]; + return ( + typeof longitude === 'number' && + Number.isFinite(longitude) && + longitude >= -180 && + longitude <= 180 && + typeof latitude === 'number' && + Number.isFinite(latitude) && + latitude >= -90 && + latitude <= 90 + ); +} + +function nullableFiniteNumber(value: unknown): value is number | null { + return value === null || (typeof value === 'number' && Number.isFinite(value)); +} + function nullableString(value: unknown): value is string | null { return value === null || typeof value === 'string'; } + +export function formatAssignedRoutePaymentStatus( + status: NormalizedPaymentStatus | null, +): AssignedRoutePaymentCopy { + switch (status) { + case 'PAID_CONFIRMED': + return { + detail: 'Payment is confirmed in WooCommerce. Do not request payment at delivery.', + label: 'Paid confirmed', + tone: 'green', + }; + case 'CASH_COLLECT_REQUIRED': + return { + detail: 'Cash was selected. Collect payment directly at delivery.', + label: 'Collect cash', + tone: 'warning', + }; + case 'TRANSFER_CHECK_PENDING': + return { + detail: 'E-mail/bank transfer still needs WooCommerce/admin confirmation. Do not ask again until confirmed by dispatch.', + label: 'Transfer pending', + tone: 'warning', + }; + case 'ONLINE_PAYMENT_PENDING_OR_FAILED': + return { + detail: 'Online/card payment is not confirmed. Check with dispatch before requesting anything from the customer.', + label: 'Online pending', + tone: 'warning', + }; + case 'NOT_DELIVERABLE_OR_EXCEPTION': + return { + detail: 'WooCommerce reports a cancelled, refunded, failed, or otherwise exceptional order status.', + label: 'Payment exception', + tone: 'warning', + }; + case 'UNKNOWN_REVIEW': + return { + detail: 'Payment method/status is unknown to CLEVER Route. Ask dispatch/admin to review.', + label: 'Review payment', + tone: 'warning', + }; + case null: + return { + detail: 'No normalized payment state was provided for this stop.', + label: 'Payment unavailable', + tone: 'neutral', + }; + } +} diff --git a/src/domain/route/assignedRouteValidationScenarios.ts b/src/domain/route/assignedRouteValidationScenarios.ts new file mode 100644 index 0000000..aa6ed7b --- /dev/null +++ b/src/domain/route/assignedRouteValidationScenarios.ts @@ -0,0 +1,218 @@ +import type { AssignedRoute } from './assignedRoute'; + +export type AssignedRouteValidationTab = 'active' | 'completed' | 'upcoming'; + +export type AssignedRouteValidationScenario = { + expectedEvidence: { + hasCoordinatesForEveryStop: boolean; + hasOsrmGeometry: boolean; + hasOsrmMetrics: boolean; + safeForOperationalSmoke: boolean; + }; + id: string; + label: string; + route: AssignedRoute; + tab: AssignedRouteValidationTab; +}; + +export const assignedRouteValidationScenarios: AssignedRouteValidationScenario[] = [ + { + expectedEvidence: { + hasCoordinatesForEveryStop: true, + hasOsrmGeometry: true, + hasOsrmMetrics: true, + safeForOperationalSmoke: true, + }, + id: 'validation-upcoming-osrm-ready', + label: 'Upcoming tab — synthetic Toronto route with OSRM geometry and route-level metrics', + route: buildValidationRoute({ + deliveryDate: '2026-06-04', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', + metrics: { distanceMeters: 6120.4, durationSeconds: 1680 }, + name: '[TEST] OSRM Upcoming Route', + stops: [ + validationStop({ + address1: '100 King St W', + deliveryStopId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb1', + latitude: 43.6487, + longitude: -79.3817, + orderName: '#TEST-OSRM-1001', + postalCode: 'M5X 1A9', + recipientName: 'TEST Recipient Upcoming 1', + sequence: 1, + }), + validationStop({ + address1: '200 Queen St W', + address2: 'Unit 4', + deliveryStopId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb2', + latitude: 43.6509, + longitude: -79.3909, + orderName: '#TEST-OSRM-1002', + postalCode: 'M5V 1Z2', + recipientName: 'TEST Recipient Upcoming 2', + sequence: 2, + }), + ], + }), + tab: 'upcoming', + }, + { + expectedEvidence: { + hasCoordinatesForEveryStop: true, + hasOsrmGeometry: true, + hasOsrmMetrics: true, + safeForOperationalSmoke: true, + }, + id: 'validation-active-return-to-depot', + label: 'Active tab — synthetic route in progress with return-to-depot geometry', + route: buildValidationRoute({ + deliveryDate: '2026-06-04', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', + metrics: { distanceMeters: 8740, durationSeconds: 2460 }, + name: '[TEST] OSRM Active Return Route', + stops: [ + validationStop({ + address1: '40 College St', + deliveryStopId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb3', + latitude: 43.6613, + longitude: -79.383, + orderName: '#TEST-OSRM-2001', + postalCode: 'M5G 2J3', + recipientName: 'TEST Recipient Active 1', + sequence: 1, + status: 'IN_TRANSIT', + }), + validationStop({ + address1: '500 Bloor St W', + deliveryStopId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb4', + latitude: 43.6655, + longitude: -79.4104, + orderName: '#TEST-OSRM-2002', + postalCode: 'M5S 1Y3', + recipientName: 'TEST Recipient Active 2', + sequence: 2, + status: 'ASSIGNED', + }), + ], + }), + tab: 'active', + }, + { + expectedEvidence: { + hasCoordinatesForEveryStop: true, + hasOsrmGeometry: false, + hasOsrmMetrics: false, + safeForOperationalSmoke: true, + }, + id: 'validation-completed-osrm-fallback', + label: 'Completed tab — synthetic route with OSRM unavailable fallback fields', + route: { + ...buildValidationRoute({ + deliveryDate: '2026-06-04', + id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', + metrics: null, + name: '[TEST] OSRM Fallback Completed Route', + stops: [ + validationStop({ + address1: '1 Blue Jays Way', + deliveryStopId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbb5', + latitude: 43.6414, + longitude: -79.3894, + orderName: '#TEST-OSRM-3001', + postalCode: 'M5V 1J1', + recipientName: 'TEST Recipient Completed 1', + sequence: 1, + status: 'DELIVERED', + }), + ], + }), + routeGeometry: null, + routeMetrics: null, + routeStopPoints: [], + }, + tab: 'completed', + }, +]; + +function buildValidationRoute(input: { + deliveryDate: string; + id: string; + metrics: AssignedRoute['routeMetrics']; + name: string; + stops: AssignedRoute['stops']; +}): AssignedRoute { + const geometryCoordinates: [number, number][] = [ + [-79.3832, 43.6532], + ...input.stops.flatMap((stop) => ( + stop.coordinates === null + ? [] + : [[stop.coordinates.longitude, stop.coordinates.latitude] as [number, number]] + )), + ]; + + return { + deliveryDate: input.deliveryDate, + id: input.id, + name: input.name, + routeGeometry: geometryCoordinates.length >= 2 ? { coordinates: geometryCoordinates, type: 'LineString' } : null, + routeMapPreview: null, + routeMetrics: input.metrics, + routeStopPoints: input.stops.map((stop) => ({ + deliveryStopId: stop.deliveryStopId, + inputCoordinates: stop.coordinates === null ? null : [stop.coordinates.longitude, stop.coordinates.latitude], + name: stop.address.address1, + sequence: stop.sequence, + snapDistanceMeters: 5, + snappedCoordinates: stop.coordinates === null ? null : [stop.coordinates.longitude, stop.coordinates.latitude], + })), + shopDomain: 'validation-only.example.test', + stops: input.stops, + timezone: 'America/Toronto', + }; +} + +function validationStop(input: { + address1: string; + address2?: string; + deliveryStopId: string; + latitude: number; + longitude: number; + normalizedPaymentStatus?: AssignedRoute['stops'][number]['normalizedPaymentStatus']; + orderName: string; + postalCode: string; + recipientName: string; + sequence: number; + status?: string; +}): AssignedRoute['stops'][number] { + return { + address: { + address1: input.address1, + address2: input.address2 ?? null, + city: 'Toronto', + countryCode: 'CA', + postalCode: input.postalCode, + province: 'ON', + }, + coordinates: { + latitude: input.latitude, + longitude: input.longitude, + }, + deliveryStopId: input.deliveryStopId, + items: [ + { + name: 'Validation tomato box', + options: [{ key: 'Scenario', value: input.orderName }], + productId: 9001, + quantity: 1, + sku: null, + variationId: 0, + }, + ], + normalizedPaymentStatus: input.normalizedPaymentStatus ?? 'PAID_CONFIRMED', + orderName: input.orderName, + phone: '+14165550100', + recipientName: input.recipientName, + sequence: input.sequence, + status: input.status ?? 'ASSIGNED', + }; +} diff --git a/src/domain/route/routeSchedule.test.ts b/src/domain/route/routeSchedule.test.ts new file mode 100644 index 0000000..e66bed9 --- /dev/null +++ b/src/domain/route/routeSchedule.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { getCurrentAndFutureRouteSessions } from './routeSchedule'; + +type TestRouteSession = { + id: string; + route: { + deliveryDate: string; + id: string; + timezone: string; + }; +}; + +function createRouteSession(id: string, deliveryDate: string, timezone = 'Asia/Seoul'): TestRouteSession { + return { + id, + route: { + deliveryDate, + id, + timezone, + }, + }; +} + +describe('route schedule list', () => { + it('drops past route dates and orders current/future routes nearest first', () => { + const sessions = [ + createRouteSession('future-far', '2026-05-25'), + createRouteSession('past', '2026-05-17'), + createRouteSession('future-near', '2026-05-19'), + createRouteSession('today', '2026-05-18'), + ]; + + const visibleSessions = getCurrentAndFutureRouteSessions(sessions, { + now: new Date('2026-05-18T10:00:00+09:00'), + }); + + assert.deepEqual( + visibleSessions.map((session) => session.id), + ['today', 'future-near', 'future-far'], + ); + }); + + it('uses the route timezone when deciding whether a delivery date is past', () => { + const sessions = [ + createRouteSession('toronto-today', '2026-05-17', 'America/Toronto'), + createRouteSession('seoul-yesterday', '2026-05-17', 'Asia/Seoul'), + createRouteSession('seoul-today', '2026-05-18', 'Asia/Seoul'), + ]; + + const visibleSessions = getCurrentAndFutureRouteSessions(sessions, { + now: new Date('2026-05-18T01:00:00.000Z'), + }); + + assert.deepEqual( + visibleSessions.map((session) => session.id), + ['toronto-today', 'seoul-today'], + ); + }); +}); diff --git a/src/domain/route/routeSchedule.ts b/src/domain/route/routeSchedule.ts new file mode 100644 index 0000000..e5ebc4d --- /dev/null +++ b/src/domain/route/routeSchedule.ts @@ -0,0 +1,100 @@ +export type RouteScheduleSession = { + route: { + deliveryDate: string; + timezone?: string | null; + }; +}; + +type RouteScheduleEntry = { + deliveryDateKey: string; + originalIndex: number; + session: Session; +}; + +export function getCurrentAndFutureRouteSessions( + sessions: readonly Session[], + input: { now: Date }, +): Session[] { + return sessions + .map((session, originalIndex): RouteScheduleEntry | null => { + const deliveryDateKey = normalizeDeliveryDateKey(session.route.deliveryDate); + if (deliveryDateKey === null) { + return null; + } + + const todayKey = getTodayKeyForRoute(session, input.now); + if (deliveryDateKey < todayKey) { + return null; + } + + return { + deliveryDateKey, + originalIndex, + session, + }; + }) + .filter((entry): entry is RouteScheduleEntry => entry !== null) + .sort((left, right) => left.deliveryDateKey.localeCompare(right.deliveryDateKey) || left.originalIndex - right.originalIndex) + .map((entry) => entry.session); +} + +function getTodayKeyForRoute(session: RouteScheduleSession, now: Date): string { + const timezone = session.route.timezone?.trim(); + if (timezone === undefined || timezone.length === 0) { + return toLocalDateKey(now); + } + + return toTimezoneDateKey(now, timezone) ?? toLocalDateKey(now); +} + +function normalizeDeliveryDateKey(deliveryDate: string): string | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(deliveryDate.trim()); + if (match === null) { + return null; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day); + + if ( + date.getFullYear() !== year || + date.getMonth() !== month - 1 || + date.getDate() !== day + ) { + return null; + } + + return `${match[1]}-${match[2]}-${match[3]}`; +} + +function toTimezoneDateKey(date: Date, timezone: string): string | null { + try { + const parts = new Intl.DateTimeFormat('en-US', { + day: '2-digit', + month: '2-digit', + timeZone: timezone, + year: 'numeric', + }).formatToParts(date); + const year = parts.find((part) => part.type === 'year')?.value; + const month = parts.find((part) => part.type === 'month')?.value; + const day = parts.find((part) => part.type === 'day')?.value; + + if (year === undefined || month === undefined || day === undefined) { + return null; + } + + return `${year}-${month}-${day}`; + } catch { + return null; + } +} + +function toLocalDateKey(date: Date): string { + return [ + String(date.getFullYear()).padStart(4, '0'), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0'), + ].join('-'); +} diff --git a/src/domain/route/routeSessionClassification.test.ts b/src/domain/route/routeSessionClassification.test.ts new file mode 100644 index 0000000..382f22f --- /dev/null +++ b/src/domain/route/routeSessionClassification.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + classifyAssignedRouteSession, + filterVisibleAssignedRouteSessions, + getInitialAssignedRouteTab, + type RouteSessionClassificationRoute, +} from './routeSessionClassification'; + +const now = new Date('2026-06-02T13:00:00.000Z'); + +describe('assigned route session classification', () => { + it('retains past assigned routes as unfinished instead of dropping them', () => { + const sessions = [ + session('past', '2026-05-30', 'America/Toronto'), + session('today', '2026-06-02', 'America/Toronto'), + session('future', '2026-06-03', 'America/Toronto'), + ]; + + assert.deepEqual( + filterVisibleAssignedRouteSessions(sessions, { + now, + selectedRouteId: null, + selectedRouteStatus: 'upcoming', + selectedTab: 'unfinished', + }).map((item) => item.route.id), + ['past'], + ); + assert.deepEqual( + filterVisibleAssignedRouteSessions(sessions, { + now, + selectedRouteId: null, + selectedRouteStatus: 'upcoming', + selectedTab: 'upcoming', + }).map((item) => item.route.id), + ['today', 'future'], + ); + }); + + it('uses the route timezone for date boundaries', () => { + const boundaryNow = new Date('2026-06-02T03:30:00.000Z'); + + assert.equal( + classifyAssignedRouteSession({ + now: boundaryNow, + route: route('toronto-today', '2026-06-01', 'America/Toronto'), + selectedRouteId: null, + selectedRouteStatus: 'upcoming', + }), + 'upcoming', + ); + assert.equal( + classifyAssignedRouteSession({ + now: boundaryNow, + route: route('seoul-past', '2026-06-01', 'Asia/Seoul'), + selectedRouteId: null, + selectedRouteStatus: 'upcoming', + }), + 'unfinished', + ); + }); + + it('lets the current selected route active state override date classification', () => { + assert.equal( + classifyAssignedRouteSession({ + now, + route: route('selected', '2026-05-30', 'America/Toronto'), + selectedRouteId: 'selected', + selectedRouteStatus: 'active', + }), + 'active', + ); + }); + + it('lets current-session completion override unfinished only for the selected route', () => { + assert.equal( + classifyAssignedRouteSession({ + now, + route: route('selected', '2026-05-30', 'America/Toronto'), + selectedRouteId: 'selected', + selectedRouteStatus: 'completed', + }), + 'completed', + ); + assert.equal( + classifyAssignedRouteSession({ + now, + route: route('other', '2026-05-30', 'America/Toronto'), + selectedRouteId: 'selected', + selectedRouteStatus: 'completed', + }), + 'unfinished', + ); + }); + + it('opens the first loaded past assigned route on the Unfinished tab', () => { + assert.equal( + getInitialAssignedRouteTab({ + now, + route: route('past-assigned', '2026-05-30', 'America/Toronto'), + }), + 'unfinished', + ); + }); +}); + +function route(id: string, deliveryDate: string, timezone: string): RouteSessionClassificationRoute { + return { deliveryDate, id, timezone }; +} + +function session(id: string, deliveryDate: string, timezone: string): { route: RouteSessionClassificationRoute } { + return { route: route(id, deliveryDate, timezone) }; +} diff --git a/src/domain/route/routeSessionClassification.ts b/src/domain/route/routeSessionClassification.ts new file mode 100644 index 0000000..ba8e5ef --- /dev/null +++ b/src/domain/route/routeSessionClassification.ts @@ -0,0 +1,85 @@ +export type RouteSessionStatus = 'active' | 'completed' | 'unfinished' | 'upcoming'; + +export type RouteSessionClassificationRoute = { + deliveryDate: string; + id: string; + timezone: string; +}; + +export type RouteSessionClassificationInput = { + now: Date; + route: RouteSessionClassificationRoute; + selectedRouteId: string | null; + selectedRouteStatus: RouteSessionStatus; +}; + +const DATE_ONLY_PATTERN = /^\d{4}-\d{2}-\d{2}$/u; + +export function classifyAssignedRouteSession(input: RouteSessionClassificationInput): RouteSessionStatus { + if ( + input.route.id === input.selectedRouteId && + (input.selectedRouteStatus === 'active' || input.selectedRouteStatus === 'completed') + ) { + return input.selectedRouteStatus; + } + + if (isRouteLocalPastDate({ + deliveryDate: input.route.deliveryDate, + now: input.now, + timezone: input.route.timezone, + })) { + return 'unfinished'; + } + + return 'upcoming'; +} + +export function filterVisibleAssignedRouteSessions( + sessions: T[], + input: Omit & { selectedTab: RouteSessionStatus }, +): T[] { + return sessions.filter((session) => classifyAssignedRouteSession({ + now: input.now, + route: session.route, + selectedRouteId: input.selectedRouteId, + selectedRouteStatus: input.selectedRouteStatus, + }) === input.selectedTab); +} + +export function getInitialAssignedRouteTab(input: { + now: Date; + route: RouteSessionClassificationRoute; +}): RouteSessionStatus { + return classifyAssignedRouteSession({ + now: input.now, + route: input.route, + selectedRouteId: null, + selectedRouteStatus: 'upcoming', + }); +} + +function isRouteLocalPastDate(input: { deliveryDate: string; now: Date; timezone: string }): boolean { + if (!DATE_ONLY_PATTERN.test(input.deliveryDate)) { + return false; + } + + return input.deliveryDate < getDateKeyInTimezone(input.now, input.timezone); +} + +function getDateKeyInTimezone(date: Date, timezone: string): string { + const formatter = new Intl.DateTimeFormat('en-CA', { + day: '2-digit', + month: '2-digit', + timeZone: timezone, + year: 'numeric', + }); + const parts = formatter.formatToParts(date); + const year = findDatePart(parts, 'year'); + const month = findDatePart(parts, 'month'); + const day = findDatePart(parts, 'day'); + return `${year}-${month}-${day}`; +} + +function findDatePart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string { + return parts.find((part) => part.type === type)?.value ?? '00'; +} diff --git a/src/domain/route/routeStepProgress.test.ts b/src/domain/route/routeStepProgress.test.ts new file mode 100644 index 0000000..ef7f641 --- /dev/null +++ b/src/domain/route/routeStepProgress.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { sampleAssignedRoute } from './assignedRoute'; +import { getStopDetailsProgressState, ROUTE_COMPANY_STEP_INDEX } from './routeStepProgress'; + +describe('route step progress state', () => { + it('treats route-detail stop taps as read-only previews before company pickup is explicitly confirmed', () => { + const firstStop = sampleAssignedRoute.stops[0]; + assert.ok(firstStop); + + const state = getStopDetailsProgressState({ + navigationStepIndex: ROUTE_COMPANY_STEP_INDEX, + route: sampleAssignedRoute, + selectedStopDetailsId: firstStop.deliveryStopId, + }); + + assert.equal(state?.kind, 'preview_stop'); + assert.equal(state?.stop.deliveryStopId, firstStop.deliveryStopId); + assert.equal(state?.canMarkArrived, false); + }); + + it('allows arrived handling only for the currently active stop', () => { + const firstStop = sampleAssignedRoute.stops[0]; + const secondStop = sampleAssignedRoute.stops[1]; + assert.ok(firstStop); + assert.ok(secondStop); + + assert.deepEqual( + getStopDetailsProgressState({ + navigationStepIndex: 1, + route: sampleAssignedRoute, + selectedStopDetailsId: firstStop.deliveryStopId, + }), + { + canMarkArrived: true, + kind: 'current_stop', + stop: firstStop, + }, + ); + + const previewState = getStopDetailsProgressState({ + navigationStepIndex: 1, + route: sampleAssignedRoute, + selectedStopDetailsId: secondStop.deliveryStopId, + }); + + assert.equal(previewState?.kind, 'preview_stop'); + assert.equal(previewState?.canMarkArrived, false); + assert.equal(previewState?.stop.deliveryStopId, secondStop.deliveryStopId); + }); +}); diff --git a/src/domain/route/routeStepProgress.ts b/src/domain/route/routeStepProgress.ts new file mode 100644 index 0000000..070ecd4 --- /dev/null +++ b/src/domain/route/routeStepProgress.ts @@ -0,0 +1,55 @@ +import type { AssignedRoute, AssignedRouteStop } from './assignedRoute'; + +export const ROUTE_COMPANY_STEP_INDEX = 0; + +export type StopDetailsProgressState = + | { + canMarkArrived: true; + kind: 'current_stop'; + stop: AssignedRouteStop; + } + | { + canMarkArrived: false; + kind: 'preview_stop'; + stop: AssignedRouteStop; + }; + +export function getCurrentRouteStop(input: { + navigationStepIndex: number; + route: Pick; +}): AssignedRouteStop | null { + return input.route.stops[input.navigationStepIndex - 1] ?? null; +} + +export function getStopDetailsProgressState(input: { + navigationStepIndex: number; + route: Pick; + selectedStopDetailsId: string | null; +}): StopDetailsProgressState | null { + const currentStop = getCurrentRouteStop({ + navigationStepIndex: input.navigationStepIndex, + route: input.route, + }); + const selectedStop = input.selectedStopDetailsId === null + ? null + : input.route.stops.find((stop) => stop.deliveryStopId === input.selectedStopDetailsId) ?? null; + const stop = selectedStop ?? currentStop; + + if (stop === null) { + return null; + } + + if (currentStop?.deliveryStopId === stop.deliveryStopId) { + return { + canMarkArrived: true, + kind: 'current_stop', + stop, + }; + } + + return { + canMarkArrived: false, + kind: 'preview_stop', + stop, + }; +} diff --git a/src/domain/routeAccess/driverAccountRouteAccess.contract.test.ts b/src/domain/routeAccess/driverAccountRouteAccess.contract.test.ts new file mode 100644 index 0000000..c85121a --- /dev/null +++ b/src/domain/routeAccess/driverAccountRouteAccess.contract.test.ts @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { it } from 'node:test'; + +import { createRouteAccessApiClient } from './routeAccess'; + +it('uses the account bearer and never submits the phone during route lookup', async () => { + let requestBody: Record = {}; + let requestHeaders: Record = {}; + const client = createRouteAccessApiClient({ + baseUrl: 'https://delivery.example.com', + fetchImpl: async (_url, init) => { + requestBody = JSON.parse(init?.body ?? '{}') as Record; + requestHeaders = init?.headers ?? {}; + return { + json: async () => ({ data: { status: 'ROUTES_FOUND', routes: [] }, error: null }), + ok: true, + status: 200, + }; + }, + }); + + await client.lookupRouteAccess({ accountAccessToken: 'account-access-token', routeContext: null }); + + assert.deepEqual(requestBody, { routeContext: null }); + assert.equal('phoneE164' in requestBody, false); + assert.equal(requestHeaders.Authorization, 'Bearer account-access-token'); +}); diff --git a/src/domain/routeAccess/routeAccess.test.ts b/src/domain/routeAccess/routeAccess.test.ts index 5e1673c..48b9c6b 100644 --- a/src/domain/routeAccess/routeAccess.test.ts +++ b/src/domain/routeAccess/routeAccess.test.ts @@ -10,14 +10,14 @@ import { } from './routeAccess'; describe('driver route access UX flow', () => { - it('accepts phone-only access and maps returned routes to selectable route choices', async () => { + it('uses account access and maps returned routes to selectable route choices', async () => { let lookupCalls = 0; const result = await submitRouteAccess( - { phoneE164: '+14165550123' }, + { accountAccessToken: 'account-access-token' }, { lookupRouteAccess: async (input) => { lookupCalls += 1; - assert.deepEqual(input, { phoneE164: '+14165550123', routeContext: null }); + assert.deepEqual(input, { accountAccessToken: 'account-access-token', routeContext: null }); return { status: 'ROUTES_FOUND', routes: [sampleInvitedRouteAccess] }; }, }, @@ -32,7 +32,7 @@ describe('driver route access UX flow', () => { it('accepts a registered phone with no active routes as an empty route choice list', async () => { const result = await submitRouteAccess( - { phoneE164: '+14165550123' }, + { accountAccessToken: 'account-access-token' }, createMockRouteAccessService({ status: 'ROUTES_FOUND', routes: [] }), ); @@ -43,7 +43,7 @@ describe('driver route access UX flow', () => { it('maps invited lookup to company guidance before consent', async () => { const result = await submitRouteAccess( - { routeContext: ' 11111111-1111-4111-8111-111111111111 ', phoneE164: '+14165550123' }, + { accountAccessToken: 'account-access-token', routeContext: ' 11111111-1111-4111-8111-111111111111 ' }, createMockRouteAccessService(), ); @@ -60,7 +60,7 @@ describe('driver route access UX flow', () => { it('maps denial statuses to safe app messages', () => { assert.equal( getRouteAccessDeniedMessage('NOT_FOUND'), - 'No active route is assigned to this phone number. Check the phone number or contact dispatch.', + 'No active route is assigned to this account. Contact dispatch if you expected an assignment.', ); assert.equal(getRouteAccessDeniedMessage('DISABLED'), 'This driver profile is inactive. Contact dispatch before continuing.'); assert.equal(getRouteAccessDeniedMessage('BLOCKED'), 'This driver profile is blocked. Contact dispatch before continuing.'); @@ -87,7 +87,7 @@ describe('driver route access UX flow', () => { }; const result = await submitRouteAccess( - { phoneE164: '+14165550123' }, + { accountAccessToken: 'account-access-token' }, createMockRouteAccessService({ status: 'ROUTES_FOUND', routes: [sampleInvitedRouteAccess, secondRoute] }), ); @@ -100,7 +100,7 @@ describe('driver route access UX flow', () => { it('maps legacy ambiguous route-scope matches to guidance', async () => { const result = await submitRouteAccess( - { routeContext: 'shared-dispatch-code', phoneE164: '+14165550123' }, + { accountAccessToken: 'account-access-token', routeContext: 'shared-dispatch-code' }, createMockRouteAccessService({ status: 'MULTIPLE_MATCHES', matches: [ @@ -119,7 +119,7 @@ describe('driver route access UX flow', () => { timezone: 'America/Toronto', }, ], - resolutionHint: 'Use the phone-only route list or contact dispatch.', + resolutionHint: 'Use the account route list or contact dispatch.', }), ); @@ -127,14 +127,14 @@ describe('driver route access UX flow', () => { assert.equal(result.flowState, 'route_context_entered'); assert.equal(result.matches.length, 2); assert.equal(result.matches[0].companyDisplayName, 'Tomatono Toronto'); - assert.equal(result.message, 'Multiple route assignments matched. Use the phone-only route list or contact dispatch.'); + assert.equal(result.message, 'Multiple route assignments matched. Use the account route list or contact dispatch.'); assert.equal(JSON.stringify(result).includes('routePlanId'), false); assert.equal(JSON.stringify(result).includes('accessToken'), false); assert.equal(JSON.stringify(result).includes('deliveryStop'), false); assert.equal(JSON.stringify(result).includes('address1'), false); }); - it('posts phone-only lookup requests to the delivery-server contract endpoint', async () => { + it('posts account-authenticated lookup requests to the delivery-server contract endpoint', async () => { const requests: { body: unknown; cache?: string; credentials?: string; headers: Record; method: string; url: string }[] = []; const client = createRouteAccessApiClient({ baseUrl: 'https://delivery.example.com', @@ -155,14 +155,13 @@ describe('driver route access UX flow', () => { }); const result = await client.lookupRouteAccess({ - phoneE164: '+14165550123', + accountAccessToken: 'account-access-token', }); assert.deepEqual(result, { status: 'NOT_FOUND' }); assert.deepEqual(requests, [ { body: { - phoneE164: '+14165550123', routeContext: null, }, cache: 'no-store', @@ -170,6 +169,7 @@ describe('driver route access UX flow', () => { headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache', + Authorization: 'Bearer account-access-token', 'Content-Type': 'application/json', }, method: 'POST', @@ -202,7 +202,7 @@ describe('driver route access UX flow', () => { }); const result = await client.lookupRouteAccess({ - phoneE164: '+14165550123', + accountAccessToken: 'account-access-token', }); assert.equal(result.status, 'INVITED'); @@ -212,7 +212,7 @@ describe('driver route access UX flow', () => { - it('parses route choices with driver access tokens from phone-only lookup responses', async () => { + it('parses route choices with scoped driver access tokens', async () => { const client = createRouteAccessApiClient({ baseUrl: 'https://delivery.example.com', fetchImpl: async () => ({ @@ -239,7 +239,43 @@ describe('driver route access UX flow', () => { }), }); - const result = await client.lookupRouteAccess({ phoneE164: '+14165550123' }); + const result = await client.lookupRouteAccess({ accountAccessToken: 'account-access-token' }); + + assert.equal(result.status, 'ROUTES_FOUND'); + assert.equal(result.routes.length, 1); + assert.equal(result.routes[0].driverAccess.accessToken, 'server-issued-driver-jwt'); + }); + + it('accepts the current server route-choice envelope with nested invited status', async () => { + const client = createRouteAccessApiClient({ + baseUrl: 'https://delivery.example.com', + fetchImpl: async () => ({ + ok: true, + json: async () => ({ + data: { + status: 'ROUTES_FOUND', + routes: [ + { + status: 'INVITED', + routeAccess: sampleInvitedRouteAccess.routeAccess, + companyGuidance: sampleInvitedRouteAccess.companyGuidance, + driverAccess: { + accessToken: 'server-issued-driver-jwt', + expiresAt: '2026-05-12T06:55:00.000Z', + scopes: ['route:assigned:read'], + tokenType: 'Bearer', + ttlSeconds: 900, + use: 'consent_and_assigned_route', + }, + }, + ], + }, + error: null, + }), + }), + }); + + const result = await client.lookupRouteAccess({ accountAccessToken: 'account-access-token' }); assert.equal(result.status, 'ROUTES_FOUND'); assert.equal(result.routes.length, 1); @@ -261,7 +297,7 @@ describe('driver route access UX flow', () => { }), }); - const result = await client.lookupRouteAccess({ phoneE164: '+14165550123' }); + const result = await client.lookupRouteAccess({ accountAccessToken: 'account-access-token' }); assert.equal(result.status, 'ROUTES_FOUND'); assert.equal(result.routes.length, 0); @@ -284,7 +320,7 @@ describe('driver route access UX flow', () => { timezone: 'America/Toronto', }, ], - resolutionHint: 'Use the phone-only route list or contact dispatch.', + resolutionHint: 'Use the account route list or contact dispatch.', }, error: null, }), @@ -292,8 +328,8 @@ describe('driver route access UX flow', () => { }); const result = await client.lookupRouteAccess({ + accountAccessToken: 'account-access-token', routeContext: 'shared-dispatch-code', - phoneE164: '+14165550123', }); assert.equal(result.status, 'MULTIPLE_MATCHES'); @@ -333,7 +369,7 @@ describe('driver route access UX flow', () => { }); await assert.rejects( - () => client.lookupRouteAccess({ routeContext: 'shared-dispatch-code', phoneE164: '+14165550123' }), + () => client.lookupRouteAccess({ accountAccessToken: 'account-access-token', routeContext: 'shared-dispatch-code' }), /Invalid route access response/u, ); }); @@ -355,7 +391,7 @@ describe('driver route access UX flow', () => { }); await assert.rejects( - () => client.lookupRouteAccess({ routeContext: 'route-context', phoneE164: '+14165550123' }), + () => client.lookupRouteAccess({ accountAccessToken: 'account-access-token', routeContext: 'route-context' }), /Invalid route access response/u, ); }); diff --git a/src/domain/routeAccess/routeAccess.ts b/src/domain/routeAccess/routeAccess.ts index 4b4cadf..446efe4 100644 --- a/src/domain/routeAccess/routeAccess.ts +++ b/src/domain/routeAccess/routeAccess.ts @@ -1,10 +1,10 @@ -import { getInitialAccessValidation, type DriverFlowState } from '../driverFlow/driverFlow'; +import type { DriverFlowState } from '../driverFlow/driverFlow'; import { createDriverApiHttpError } from '../../api/deliveryServer/driverApiError'; import { withNoStoreDriverApiRequest } from '../../api/deliveryServer/driverApiRequestOptions'; export type RouteAccessLookupInput = { + accountAccessToken: string; routeContext?: string | null; - phoneE164: string; }; export type RouteAccessCompanyGuidance = { @@ -34,6 +34,8 @@ export type DriverAccessToken = { tokenType: 'Bearer'; ttlSeconds: number; use: 'consent_and_assigned_route'; + refreshToken?: string; + refreshTokenExpiresAt?: string; }; export type RouteAccessRouteChoice = { @@ -69,11 +71,6 @@ export type RouteAccessService = { }; export type RouteAccessSubmissionResult = - | { - kind: 'validation_error'; - message: string; - reason: 'phone_invalid' | 'phone_required'; - } | { kind: 'route_choices'; flowState: Extract; @@ -163,7 +160,7 @@ export const sampleMultipleRouteAccess: Extract = { @@ -190,18 +187,10 @@ export async function submitRouteAccess( service: RouteAccessService, ): Promise { const routeContext = input.routeContext?.trim() || null; - const phoneE164 = input.phoneE164.trim(); - const validation = getInitialAccessValidation({ routeContext, phoneE164 }); - - if (!validation.ok) { - return { - kind: 'validation_error', - message: getRouteAccessValidationMessage(validation.reason), - reason: validation.reason, - }; - } - - const lookup = await service.lookupRouteAccess({ routeContext, phoneE164 }); + const lookup = await service.lookupRouteAccess({ + accountAccessToken: input.accountAccessToken.trim(), + routeContext, + }); if (lookup.status === 'INVITED') { return { kind: 'company_guidance', @@ -238,25 +227,14 @@ export async function submitRouteAccess( }; } -export function getRouteAccessValidationMessage( - reason: 'phone_invalid' | 'phone_required', -): string { - switch (reason) { - case 'phone_required': - return 'Enter the driver phone number in E.164 format.'; - case 'phone_invalid': - return 'Use E.164 phone format, for example +14165550123.'; - } -} - export function getRouteAccessMultipleMatchesMessage(): string { - return 'Multiple route assignments matched. Use the phone-only route list or contact dispatch.'; + return 'Multiple route assignments matched. Use the account route list or contact dispatch.'; } export function getRouteAccessDeniedMessage(status: 'BLOCKED' | 'DISABLED' | 'NOT_FOUND'): string { switch (status) { case 'NOT_FOUND': - return 'No active route is assigned to this phone number. Check the phone number or contact dispatch.'; + return 'No active route is assigned to this account. Contact dispatch if you expected an assignment.'; case 'DISABLED': return 'This driver profile is inactive. Contact dispatch before continuing.'; case 'BLOCKED': @@ -266,7 +244,7 @@ export function getRouteAccessDeniedMessage(status: 'BLOCKED' | 'DISABLED' | 'NO const MULTIPLE_MATCHES_RESPONSE_KEYS = new Set(['matches', 'resolutionHint', 'status']); const ROUTES_FOUND_RESPONSE_KEYS = new Set(['routes', 'status']); -const ROUTE_CHOICE_KEYS = new Set(['companyGuidance', 'driverAccess', 'routeAccess']); +const ROUTE_CHOICE_KEYS = new Set(['companyGuidance', 'driverAccess', 'routeAccess', 'status']); const MULTIPLE_MATCH_KEYS = new Set([ 'companyDisplayName', 'deliveryDate', @@ -288,10 +266,12 @@ export function createRouteAccessApiClient(input: { lookupRouteAccess: async (request) => { const response = await fetchImpl(`${baseUrl}/driver/route-access/lookup`, withNoStoreDriverApiRequest({ body: JSON.stringify({ - phoneE164: request.phoneE164, routeContext: request.routeContext?.trim() || null, }), - headers: { 'Content-Type': 'application/json' }, + headers: { + Authorization: `Bearer ${request.accountAccessToken.trim()}`, + 'Content-Type': 'application/json', + }, method: 'POST', })); const payload = await response.json(); @@ -375,6 +355,7 @@ function isRouteAccessRouteChoice(value: unknown): value is RouteAccessRouteChoi const choice = value as Record; return ( + (choice.status === undefined || choice.status === 'INVITED') && isRouteAccess(choice.routeAccess) && isDriverAccessToken(choice.driverAccess) && isCompanyGuidance(choice.companyGuidance) @@ -449,7 +430,9 @@ export function isDriverAccessToken(value: unknown): value is DriverAccessToken typeof token.ttlSeconds === 'number' && Number.isFinite(token.ttlSeconds) && token.ttlSeconds > 0 && - token.use === 'consent_and_assigned_route' + token.use === 'consent_and_assigned_route' && + (token.refreshToken === undefined || typeof token.refreshToken === 'string') && + (token.refreshTokenExpiresAt === undefined || typeof token.refreshTokenExpiresAt === 'string') ); } diff --git a/src/domain/stop/stopNavigation.test.ts b/src/domain/stop/stopNavigation.test.ts index c3e3c07..925f850 100644 --- a/src/domain/stop/stopNavigation.test.ts +++ b/src/domain/stop/stopNavigation.test.ts @@ -1,9 +1,11 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { sampleAssignedRoute, type AssignedRouteStop } from '../route/assignedRoute'; +import { sampleAssignedRoute, type AssignedRoute, type AssignedRouteStop } from '../route/assignedRoute'; import { + buildRouteNavigationUrl, buildStopNavigationUrl, + openRouteNavigation, openStopNavigation, } from './stopNavigation'; @@ -79,3 +81,97 @@ describe('native stop map launch', () => { assert.deepEqual(openedUrls, ['http://maps.apple.com/?ll=43.6487,-79.3817&q=Stop%201%20%231001']); }); }); + +describe('route map launch', () => { + it('builds map-app directions from stop addresses in route sequence before using coordinates', () => { + assert.equal( + buildRouteNavigationUrl({ route: sampleAssignedRoute }), + 'https://www.google.com/maps/dir/?api=1&travelmode=driving&dir_action=navigate&destination=200%20Queen%20St%20W%2C%20Unit%204%2C%20Toronto%2C%20ON%2C%20M5V%201Z2%2C%20CA&waypoints=100%20King%20St%20W%2C%20Toronto%2C%20ON%2C%20M5X%201A9%2C%20CA', + ); + }); + + it('falls back to stop coordinates when addresses are unavailable', () => { + const routeWithoutAddresses: AssignedRoute = { + ...sampleAssignedRoute, + routeStopPoints: [], + stops: sampleAssignedRoute.stops.map((stop) => ({ + ...stop, + address: { + address1: '', + address2: null, + city: '', + countryCode: '', + postalCode: '', + province: '', + }, + })), + }; + + assert.equal( + buildRouteNavigationUrl({ route: routeWithoutAddresses }), + 'https://www.google.com/maps/dir/?api=1&travelmode=driving&dir_action=navigate&destination=43.6509%2C-79.3909&waypoints=43.6487%2C-79.3817', + ); + }); + + it('uses OSRM stop point coordinates only when both addresses and stop coordinates are unavailable', () => { + const routeWithoutAddressesOrStopCoordinates: AssignedRoute = { + ...sampleAssignedRoute, + stops: sampleAssignedRoute.stops.map((stop) => ({ + ...stop, + address: { + address1: '', + address2: null, + city: '', + countryCode: '', + postalCode: '', + province: '', + }, + coordinates: null, + })), + }; + + assert.equal( + buildRouteNavigationUrl({ route: routeWithoutAddressesOrStopCoordinates }), + 'https://www.google.com/maps/dir/?api=1&travelmode=driving&dir_action=navigate&destination=43.651%2C-79.391&waypoints=43.6488%2C-79.3818', + ); + }); + + it('opens route directions through the native linking boundary', async () => { + const openedUrls: string[] = []; + + const result = await openRouteNavigation({ + linking: { openURL: async (url) => openedUrls.push(url) }, + route: sampleAssignedRoute, + }); + + assert.deepEqual(result, { + kind: 'opened', + message: 'Opened 2 stops in the map app.', + url: 'https://www.google.com/maps/dir/?api=1&travelmode=driving&dir_action=navigate&destination=200%20Queen%20St%20W%2C%20Unit%204%2C%20Toronto%2C%20ON%2C%20M5V%201Z2%2C%20CA&waypoints=100%20King%20St%20W%2C%20Toronto%2C%20ON%2C%20M5X%201A9%2C%20CA', + }); + assert.deepEqual(openedUrls, [ + 'https://www.google.com/maps/dir/?api=1&travelmode=driving&dir_action=navigate&destination=200%20Queen%20St%20W%2C%20Unit%204%2C%20Toronto%2C%20ON%2C%20M5V%201Z2%2C%20CA&waypoints=100%20King%20St%20W%2C%20Toronto%2C%20ON%2C%20M5X%201A9%2C%20CA', + ]); + }); + + it('skips launch when a route has no usable destination', async () => { + const emptyRoute: AssignedRoute = { + ...sampleAssignedRoute, + routeStopPoints: [], + stops: [], + }; + const openedUrls: string[] = []; + + const result = await openRouteNavigation({ + linking: { openURL: async (url) => openedUrls.push(url) }, + route: emptyRoute, + }); + + assert.deepEqual(result, { + kind: 'skipped', + message: 'Route has no destinations to open in maps.', + reason: 'missing_destination', + }); + assert.deepEqual(openedUrls, []); + }); +}); diff --git a/src/domain/stop/stopNavigation.ts b/src/domain/stop/stopNavigation.ts index 2cd20e6..c60ce3c 100644 --- a/src/domain/stop/stopNavigation.ts +++ b/src/domain/stop/stopNavigation.ts @@ -1,4 +1,4 @@ -import type { AssignedRouteAddress, AssignedRouteStop } from '../route/assignedRoute'; +import type { AssignedRoute, AssignedRouteAddress, AssignedRouteLngLat, AssignedRouteStop } from '../route/assignedRoute'; export type StopNavigationPlatform = 'android' | 'ios' | string; @@ -24,6 +24,82 @@ export type StopNavigationLinking = { openURL(url: string): Promise | unknown; }; +export type RouteNavigationResult = + | { + kind: 'opened'; + message: string; + url: string; + } + | { + kind: 'skipped'; + message: string; + reason: 'missing_destination'; + } + | { + kind: 'failed'; + message: string; + reason: 'open_failed'; + url: string; + }; + +type RouteNavigationTarget = { + value: string; +}; + +export function buildRouteNavigationUrl(input: { + route: AssignedRoute; +}): string | null { + const targets = buildRouteNavigationTargets(input.route); + if (targets.length === 0) { + return null; + } + + const destination = targets[targets.length - 1]!; + const waypoints = targets.slice(0, -1); + const params = [ + 'api=1', + 'travelmode=driving', + 'dir_action=navigate', + `destination=${encodeURIComponent(destination.value)}`, + ]; + + if (waypoints.length > 0) { + params.push(`waypoints=${waypoints.map((target) => encodeURIComponent(target.value)).join('%7C')}`); + } + + return `https://www.google.com/maps/dir/?${params.join('&')}`; +} + +export async function openRouteNavigation(input: { + linking: StopNavigationLinking; + route: AssignedRoute; +}): Promise { + const url = buildRouteNavigationUrl({ route: input.route }); + if (url === null) { + return { + kind: 'skipped', + message: 'Route has no destinations to open in maps.', + reason: 'missing_destination', + }; + } + + try { + await input.linking.openURL(url); + return { + kind: 'opened', + message: `Opened ${formatStopCount(input.route.stops.length)} in the map app.`, + url, + }; + } catch { + return { + kind: 'failed', + message: 'Map app could not be opened for this route.', + reason: 'open_failed', + url, + }; + } +} + export function buildStopNavigationUrl(input: { platform: StopNavigationPlatform; stop: AssignedRouteStop; @@ -116,3 +192,39 @@ export function formatStopNavigationAddress(address: AssignedRouteAddress): stri function formatCoordinatePair(latitude: number, longitude: number): string { return `${latitude},${longitude}`; } + +function buildRouteNavigationTargets(route: AssignedRoute): RouteNavigationTarget[] { + const stopPointsById = new Map( + route.routeStopPoints.map((point) => [point.deliveryStopId, point] as const), + ); + + return [...route.stops] + .sort((left, right) => left.sequence - right.sequence) + .map((stop) => { + const address = formatStopNavigationAddress(stop.address); + if (address !== null) { + return { value: address }; + } + + const stopPoint = stopPointsById.get(stop.deliveryStopId); + const coordinates = stopPoint?.snappedCoordinates ?? stopPoint?.inputCoordinates ?? stopCoordinatesToLngLat(stop.coordinates); + if (coordinates !== null) { + return { value: formatLngLatForDirections(coordinates) }; + } + + return null; + }) + .filter((target): target is RouteNavigationTarget => target !== null); +} + +function stopCoordinatesToLngLat(coordinates: AssignedRouteStop['coordinates']): AssignedRouteLngLat | null { + return coordinates === null ? null : [coordinates.longitude, coordinates.latitude]; +} + +function formatLngLatForDirections(coordinates: AssignedRouteLngLat): string { + return formatCoordinatePair(coordinates[1], coordinates[0]); +} + +function formatStopCount(stopCount: number): string { + return `${stopCount} ${stopCount === 1 ? 'stop' : 'stops'}`; +} diff --git a/src/domain/stop/stopProofEvents.test.ts b/src/domain/stop/stopProofEvents.test.ts index 997e5c9..b83455b 100644 --- a/src/domain/stop/stopProofEvents.test.ts +++ b/src/domain/stop/stopProofEvents.test.ts @@ -96,7 +96,7 @@ describe('stop proof event flow', () => { }); }); - it('records uploaded media, signature, and barcode proof references after delivery_active', async () => { + it('records uploaded media and signature proof references after delivery_active', async () => { const driverEventService = createMockDriverEventService(); await recordStopProofEventAfterDeliveryStart({ @@ -104,16 +104,6 @@ describe('stop proof event flow', () => { driverEventService, input: { action: 'delivered', - barcodes: [ - { - barcodeId: 'barcode-1', - capturedAt: '2026-05-12T10:10:00.000Z', - data: 'ORDER-1001', - kind: 'barcode', - source: 'native-scanner', - symbology: 'code128', - }, - ], deliveryStopId: 'stop-1', media: [ { @@ -144,16 +134,6 @@ describe('stop proof event flow', () => { assert.deepEqual(driverEventService.recordedEvents[0]?.payload, { proof: { - barcodes: [ - { - barcodeId: 'barcode-1', - capturedAt: '2026-05-12T10:10:00.000Z', - data: 'ORDER-1001', - kind: 'barcode', - source: 'native-scanner', - symbology: 'code128', - }, - ], media: [ { contentType: 'image/jpeg', diff --git a/src/domain/stop/stopProofEvents.ts b/src/domain/stop/stopProofEvents.ts index 5a6082d..af12979 100644 --- a/src/domain/stop/stopProofEvents.ts +++ b/src/domain/stop/stopProofEvents.ts @@ -2,7 +2,6 @@ import type { DeliveryStartResult } from '../delivery/deliveryStart'; import { formatDriverApiErrorForDriver, getDriverApiRequiresRouteLookup } from '../../api/deliveryServer/driverApiError'; import type { DriverEventRecordResult, DriverEventService, DriverEventType } from '../events/driverEvents'; import type { OfflineSubmissionQueue } from '../offline/offlineSubmissionQueue'; -import type { ProofBarcodeReference } from '../proof/proofBarcodeCapture'; import type { ProofMediaReference } from '../proof/proofMediaUpload'; import type { ProofSignatureReference } from '../proof/proofSignatureCapture'; @@ -11,7 +10,6 @@ export type StopProofFailureReason = 'CUSTOMER_UNAVAILABLE' | 'DAMAGED' | 'INACC export type StopProofEventInput = { action: StopProofAction; - barcodes?: ProofBarcodeReference[]; deliveryStopId: string; media?: ProofMediaReference[]; note: string; @@ -79,12 +77,10 @@ function getStopProofPayload(input: StopProofEventInput): Record { - const result = await Camera.requestCameraPermissionsAsync(); - return result.status === 'granted' ? 'granted' : 'denied'; -} - -async function launchModernBarcodeScanner(): Promise { - if (!CameraView.isModernBarcodeScannerAvailable) { - return { - kind: 'unavailable', - message: 'Native barcode scanner is unavailable on this device.', - }; - } - - return new Promise((resolve) => { - let resolved = false; - const finish = (result: ProofBarcodeScanResult) => { - if (resolved) { - return; - } - resolved = true; - subscription.remove(); - resolve(result); - }; - const subscription = CameraView.onModernBarcodeScanned((event) => { - void CameraView.dismissScanner().catch(() => undefined); - finish({ - data: event.data, - kind: 'scanned', - symbology: event.type, - }); - }); - - CameraView.launchScanner({ barcodeTypes: DEFAULT_BARCODE_TYPES }) - .then(() => { - finish({ kind: 'cancelled' }); - }) - .catch((error: unknown) => { - finish({ - kind: 'unavailable', - message: `Native barcode scanner failed: ${error instanceof Error ? error.message : 'unknown error'}`, - }); - }); - }); -} diff --git a/src/platform/expo/camera/expoProofPhotoCaptureService.ts b/src/platform/expo/camera/expoProofPhotoCaptureService.ts index 36d177b..bdd3682 100644 --- a/src/platform/expo/camera/expoProofPhotoCaptureService.ts +++ b/src/platform/expo/camera/expoProofPhotoCaptureService.ts @@ -24,7 +24,7 @@ async function requestImagePickerPermission(source: ProofPhotoCaptureSource): Pr async function launchImagePicker(source: ProofPhotoCaptureSource): Promise { const result = source === 'camera' - ? await ImagePicker.launchCameraAsync(getImagePickerOptions()) + ? await ImagePicker.launchCameraAsync({ ...getImagePickerOptions(), cameraType: ImagePicker.CameraType.back }) : await ImagePicker.launchImageLibraryAsync(getImagePickerOptions()); if (result.canceled || result.assets.length === 0) { diff --git a/src/platform/expo/notifications/expoStopArrivalNotificationService.ts b/src/platform/expo/notifications/expoStopArrivalNotificationService.ts new file mode 100644 index 0000000..4c45a45 --- /dev/null +++ b/src/platform/expo/notifications/expoStopArrivalNotificationService.ts @@ -0,0 +1,104 @@ +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +import { + formatStopArrivalNotificationContent, + parseStopArrivalNotificationData, + type StopArrivalNotificationService, +} from '../../../domain/notifications/stopArrivalNotifications'; + +const STOP_ARRIVAL_CHANNEL_ID = 'stop-arrivals'; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +export function createExpoStopArrivalNotificationService(): StopArrivalNotificationService { + return { + addStopArrivalResponseListener: (listener) => { + const subscription = Notifications.addNotificationResponseReceivedListener((response) => { + const data = parseStopArrivalNotificationData(response.notification.request.content.data); + if (data !== null) { + listener(data); + } + }); + + return () => subscription.remove(); + }, + getLastStopArrivalResponse: async () => { + const response = await Notifications.getLastNotificationResponseAsync(); + return parseStopArrivalNotificationData(response?.notification.request.content.data); + }, + registerForStopArrivalNotifications: async () => { + try { + await ensureStopArrivalNotificationChannel(); + const existingPermission = await Notifications.getPermissionsAsync(); + const finalPermission = existingPermission.status === 'granted' + ? existingPermission + : await Notifications.requestPermissionsAsync(); + + if (finalPermission.status !== 'granted') { + return { + kind: 'permission_denied', + message: 'Notification permission is required for automatic arrival alerts.', + }; + } + + const devicePushToken = await readDevicePushToken(); + return { + devicePushToken, + kind: 'registered', + }; + } catch { + return { + kind: 'unavailable', + message: 'Arrival notifications are unavailable on this build or device.', + }; + } + }, + scheduleStopArrivalNotification: async (candidate) => { + await ensureStopArrivalNotificationChannel(); + const content = formatStopArrivalNotificationContent(candidate); + await Notifications.scheduleNotificationAsync({ + content: { + body: content.body, + data: candidate.data, + sound: true, + title: content.title, + }, + trigger: null, + }); + void candidate.distanceMeters; + void candidate.radiusMeters; + }, + }; +} + +async function ensureStopArrivalNotificationChannel(): Promise { + if (Platform.OS !== 'android') { + return; + } + + await Notifications.setNotificationChannelAsync(STOP_ARRIVAL_CHANNEL_ID, { + importance: Notifications.AndroidImportance.HIGH, + name: 'Stop arrival alerts', + vibrationPattern: [0, 250, 250, 250], + }); +} + +async function readDevicePushToken(): Promise { + try { + const token = await Notifications.getDevicePushTokenAsync(); + if (typeof token.data === 'string') { + return token.data; + } + return JSON.stringify(token.data); + } catch { + return null; + } +} diff --git a/src/release/evidence/releaseEvidenceSeedCli.ts b/src/release/evidence/releaseEvidenceSeedCli.ts index bcef087..e398cc7 100644 --- a/src/release/evidence/releaseEvidenceSeedCli.ts +++ b/src/release/evidence/releaseEvidenceSeedCli.ts @@ -1,5 +1,5 @@ import { execFileSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -12,11 +12,27 @@ function readJson(relativePath: string): T { return JSON.parse(readFileSync(resolve(repoRoot, relativePath), 'utf8')) as T; } +function readOptional(relativePath: string): string | undefined { + const absolutePath = resolve(repoRoot, relativePath); + return existsSync(absolutePath) ? readFileSync(absolutePath, 'utf8') : undefined; +} + function readInput(): NativeReleasePreflightInput { + const iosProjectPbxproj = readOptional('ios/CleverDriver.xcodeproj/project.pbxproj'); + return { appConfig: readJson('app.json'), easConfig: readJson('eas.json'), - envExample: readFileSync(resolve(repoRoot, '.env.example'), 'utf8') + envExample: readFileSync(resolve(repoRoot, '.env.example'), 'utf8'), + ...(iosProjectPbxproj === undefined + ? {} + : { + iosNativeProject: { + infoPlist: readOptional('ios/CleverDriver/Info.plist'), + privacyManifest: readOptional('ios/CleverDriver/PrivacyInfo.xcprivacy'), + projectPbxproj: iosProjectPbxproj, + }, + }), }; } diff --git a/src/release/preflight/nativeReleasePreflight.test.ts b/src/release/preflight/nativeReleasePreflight.test.ts index 37f8272..7538f93 100644 --- a/src/release/preflight/nativeReleasePreflight.test.ts +++ b/src/release/preflight/nativeReleasePreflight.test.ts @@ -35,7 +35,8 @@ test('native release preflight passes for the committed Expo and EAS config', () 'expo.permissions', 'eas.preview', 'eas.production', - 'runtime.env.example' + 'runtime.env.example', + 'ios.native' ] ); }); @@ -109,3 +110,89 @@ test('native release preflight rejects accidental iOS Contacts usage description } ]); }); + + +test('native release preflight validates a source-controlled iOS project when present', () => { + const input = currentInput(); + const result = runNativeReleasePreflight({ + ...input, + iosNativeProject: { + infoPlist: [ + 'NSLocationWhenInUseUsageDescription', + 'NSLocationAlwaysAndWhenInUseUsageDescription', + 'NSCameraUsageDescription', + 'NSPhotoLibraryUsageDescription', + ].join('\n'), + privacyManifest: 'NSPrivacyTracking\n', + projectPbxproj: [ + 'MARKETING_VERSION = 1.0.0;', + 'CURRENT_PROJECT_VERSION = 1;', + 'PRODUCT_BUNDLE_IDENTIFIER = com.evns.cleverdriverapp;', + ].join('\n'), + }, + }); + + assert.equal(result.ok, true); + assert.equal(result.failures.length, 0); + assert.equal(result.checks.at(-1)?.id, 'ios.native'); +}); + +test('native release preflight rejects local Apple team pins in source-controlled iOS project', () => { + const input = currentInput(); + const result = runNativeReleasePreflight({ + ...input, + iosNativeProject: { + infoPlist: [ + 'NSLocationWhenInUseUsageDescription', + 'NSLocationAlwaysAndWhenInUseUsageDescription', + 'NSCameraUsageDescription', + 'NSPhotoLibraryUsageDescription', + ].join('\n'), + privacyManifest: 'NSPrivacyTracking\n', + projectPbxproj: [ + 'DEVELOPMENT_TEAM = Y4RMZPJAA7;', + 'MARKETING_VERSION = 1.0.0;', + 'CURRENT_PROJECT_VERSION = 1;', + 'PRODUCT_BUNDLE_IDENTIFIER = com.evns.cleverdriverapp;', + ].join('\n'), + }, + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.failures, [ + { + id: 'ios.native', + message: 'Source-controlled iOS project must not pin a local Apple DEVELOPMENT_TEAM.' + } + ]); +}); + +test('native release preflight rejects unapproved generated iOS permission copy', () => { + const input = currentInput(); + const result = runNativeReleasePreflight({ + ...input, + iosNativeProject: { + infoPlist: [ + 'NSLocationWhenInUseUsageDescription', + 'NSLocationAlwaysAndWhenInUseUsageDescription', + 'NSCameraUsageDescription', + 'NSPhotoLibraryUsageDescription', + 'NSMicrophoneUsageDescription', + ].join('\n'), + privacyManifest: 'NSPrivacyTracking\n', + projectPbxproj: [ + 'MARKETING_VERSION = 1.0.0;', + 'CURRENT_PROJECT_VERSION = 1;', + 'PRODUCT_BUNDLE_IDENTIFIER = com.evns.cleverdriverapp;', + ].join('\n'), + }, + }); + + assert.equal(result.ok, false); + assert.deepEqual(result.failures, [ + { + id: 'ios.native', + message: 'Microphone usage description must stay absent until an approved audio feature exists.' + } + ]); +}); diff --git a/src/release/preflight/nativeReleasePreflight.ts b/src/release/preflight/nativeReleasePreflight.ts index 44b6499..a819c16 100644 --- a/src/release/preflight/nativeReleasePreflight.ts +++ b/src/release/preflight/nativeReleasePreflight.ts @@ -3,6 +3,7 @@ export type NativeReleasePreflightCheckId = | 'eas.production' | 'expo.identity' | 'expo.permissions' + | 'ios.native' | 'runtime.env.example'; export type NativeReleasePreflightCheck = { @@ -22,7 +23,6 @@ export type NativeReleasePreflightInput = { appConfig: { expo?: { android?: { - edgeToEdgeEnabled?: boolean; package?: string; permissions?: string[]; versionCode?: number; @@ -54,6 +54,11 @@ export type NativeReleasePreflightInput = { submit?: Record; }; envExample: string; + iosNativeProject?: { + infoPlist?: string; + privacyManifest?: string; + projectPbxproj?: string; + }; }; const FORBIDDEN_CONTACTS_ANDROID_PERMISSIONS = new Set([ @@ -71,7 +76,8 @@ export function runNativeReleasePreflight(input: NativeReleasePreflightInput): N checkExpoPermissions(input.appConfig), checkEasPreview(input.easConfig), checkEasProduction(input.easConfig), - checkRuntimeEnvExample(input.envExample) + checkRuntimeEnvExample(input.envExample), + checkIosNativeProject(input.iosNativeProject, input.appConfig) ]; const failures = checks .filter((check) => !check.ok) @@ -100,8 +106,8 @@ function checkExpoIdentity(appConfig: NativeReleasePreflightInput['appConfig']): if (expo.scheme !== 'clever-driver') { return fail('expo.identity', 'Expo URL scheme must be clever-driver.'); } - if (expo.version !== '0.1.0') { - return fail('expo.identity', 'Expo app version must be 0.1.0 until owner-approved release versioning changes.'); + if (expo.version !== '1.0.0') { + return fail('expo.identity', 'Expo app version must match the approved 1.0.0 release baseline.'); } if (expo.ios?.bundleIdentifier !== 'com.evns.cleverdriverapp') { return fail('expo.identity', 'iOS bundleIdentifier must be com.evns.cleverdriverapp.'); @@ -118,9 +124,6 @@ function checkExpoIdentity(appConfig: NativeReleasePreflightInput['appConfig']): if (expo.android?.versionCode !== 1) { return fail('expo.identity', 'Android versionCode must remain 1 before the first EAS remote version sync.'); } - if (expo.android?.edgeToEdgeEnabled !== true) { - return fail('expo.identity', 'Android edgeToEdgeEnabled must stay enabled for the current Expo baseline.'); - } if (expo.extra?.projectStartIssue !== 'EVNSolution/clever-change-control#145') { return fail('expo.identity', 'Expo extra.projectStartIssue must reference EVNSolution/clever-change-control#145.'); } @@ -164,11 +167,6 @@ function checkExpoPermissions(appConfig: NativeReleasePreflightInput['appConfig' return fail('expo.permissions', 'expo-image-picker camera/photos permission copy is required.'); } - const cameraPlugin = tuplePluginConfig(plugins, 'expo-camera'); - if (cameraPlugin === null || typeof cameraPlugin.cameraPermission !== 'string' || cameraPlugin.cameraPermission.trim() === '') { - return fail('expo.permissions', 'expo-camera barcode scanner permission copy is required.'); - } - if (!plugins.includes('expo-secure-store')) { return fail('expo.permissions', 'expo-secure-store plugin is required for native driver token storage.'); } @@ -179,7 +177,57 @@ function checkExpoPermissions(appConfig: NativeReleasePreflightInput['appConfig' return fail('expo.permissions', 'Contacts/address-book permissions must stay absent from the driver app native config.'); } - return pass('expo.permissions', 'Native location, camera, photo, scanner, and secure storage permissions are declared.'); + return pass('expo.permissions', 'Native location, proof photo, and secure storage permissions are declared.'); +} + + +function checkIosNativeProject( + iosNativeProject: NativeReleasePreflightInput['iosNativeProject'], + appConfig: NativeReleasePreflightInput['appConfig'], +): NativeReleasePreflightCheck { + if (iosNativeProject === undefined) { + return pass('ios.native', 'No source-controlled iOS native project is present; Expo config remains the native source of truth.'); + } + + const pbxproj = iosNativeProject.projectPbxproj ?? ''; + const infoPlist = iosNativeProject.infoPlist ?? ''; + const privacyManifest = iosNativeProject.privacyManifest ?? ''; + const expectedVersion = appConfig.expo?.version ?? ''; + const expectedBuildNumber = appConfig.expo?.ios?.buildNumber ?? ''; + const expectedBundleIdentifier = appConfig.expo?.ios?.bundleIdentifier ?? ''; + + if (pbxproj.includes('DEVELOPMENT_TEAM =')) { + return fail('ios.native', 'Source-controlled iOS project must not pin a local Apple DEVELOPMENT_TEAM.'); + } + if (!pbxproj.includes(`MARKETING_VERSION = ${expectedVersion};`)) { + return fail('ios.native', 'iOS MARKETING_VERSION must match the Expo app version baseline.'); + } + if (!pbxproj.includes(`CURRENT_PROJECT_VERSION = ${expectedBuildNumber};`)) { + return fail('ios.native', 'iOS CURRENT_PROJECT_VERSION must match the Expo iOS buildNumber baseline.'); + } + if (!pbxproj.includes(`PRODUCT_BUNDLE_IDENTIFIER = ${expectedBundleIdentifier};`)) { + return fail('ios.native', 'iOS PRODUCT_BUNDLE_IDENTIFIER must match the Expo bundleIdentifier baseline.'); + } + if (infoPlist.includes('NSContactsUsageDescription')) { + return fail('ios.native', 'Contacts usage description must stay absent from the source-controlled iOS project.'); + } + if (infoPlist.includes('NSMicrophoneUsageDescription')) { + return fail('ios.native', 'Microphone usage description must stay absent until an approved audio feature exists.'); + } + if (infoPlist.includes('NSFaceIDUsageDescription')) { + return fail('ios.native', 'Face ID usage description must stay absent until biometric auth is approved.'); + } + if (!infoPlist.includes('NSLocationWhenInUseUsageDescription') || !infoPlist.includes('NSLocationAlwaysAndWhenInUseUsageDescription')) { + return fail('ios.native', 'Source-controlled iOS project must include active-delivery location permission copy.'); + } + if (!infoPlist.includes('NSCameraUsageDescription') || !infoPlist.includes('NSPhotoLibraryUsageDescription')) { + return fail('ios.native', 'Source-controlled iOS project must include proof photo camera/photo permission copy.'); + } + if (!privacyManifest.includes('NSPrivacyTracking') || !privacyManifest.includes('')) { + return fail('ios.native', 'iOS privacy manifest must explicitly keep tracking disabled.'); + } + + return pass('ios.native', 'Source-controlled iOS native project matches app identity, permission, and privacy guardrails.'); } function hasForbiddenContactsAndroidPermission(permissions: string[] | undefined): boolean { diff --git a/src/release/preflight/nativeReleasePreflightCli.ts b/src/release/preflight/nativeReleasePreflightCli.ts index 93c52ab..a8bc9d5 100644 --- a/src/release/preflight/nativeReleasePreflightCli.ts +++ b/src/release/preflight/nativeReleasePreflightCli.ts @@ -1,4 +1,4 @@ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,11 +13,27 @@ function readJson(relativePath: string): T { return JSON.parse(readFileSync(resolve(repoRoot, relativePath), 'utf8')) as T; } +function readOptional(relativePath: string): string | undefined { + const absolutePath = resolve(repoRoot, relativePath); + return existsSync(absolutePath) ? readFileSync(absolutePath, 'utf8') : undefined; +} + function readInput(): NativeReleasePreflightInput { + const iosProjectPbxproj = readOptional('ios/CleverDriver.xcodeproj/project.pbxproj'); + return { appConfig: readJson('app.json'), easConfig: readJson('eas.json'), - envExample: readFileSync(resolve(repoRoot, '.env.example'), 'utf8') + envExample: readFileSync(resolve(repoRoot, '.env.example'), 'utf8'), + ...(iosProjectPbxproj === undefined + ? {} + : { + iosNativeProject: { + infoPlist: readOptional('ios/CleverDriver/Info.plist'), + privacyManifest: readOptional('ios/CleverDriver/PrivacyInfo.xcprivacy'), + projectPbxproj: iosProjectPbxproj, + }, + }), }; } diff --git a/src/ui/components/TransientToast.tsx b/src/ui/components/TransientToast.tsx index 83afde9..bf78c59 100644 --- a/src/ui/components/TransientToast.tsx +++ b/src/ui/components/TransientToast.tsx @@ -1,9 +1,11 @@ import { Platform, StyleSheet, Text, View } from 'react-native'; import { + TRANSIENT_TOAST_ANDROID_ELEVATION, TRANSIENT_TOAST_ANDROID_TOP_OFFSET, TRANSIENT_TOAST_BORDER_ALPHA, TRANSIENT_TOAST_SURFACE_ALPHA, + TRANSIENT_TOAST_Z_INDEX, } from './transientToastBehavior'; export function TransientToast({ text }: { text: string }) { @@ -16,7 +18,7 @@ export function TransientToast({ text }: { text: string }) { const shadow = Platform.select({ android: { - elevation: 1, + elevation: TRANSIENT_TOAST_ANDROID_ELEVATION, }, ios: { shadowColor: '#0f172a', @@ -33,7 +35,7 @@ const styles = StyleSheet.create({ position: 'absolute', right: 22, top: Platform.select({ android: TRANSIENT_TOAST_ANDROID_TOP_OFFSET, default: 32, ios: 18 }), - zIndex: 50, + zIndex: TRANSIENT_TOAST_Z_INDEX, ...shadow, }, toastText: { diff --git a/src/ui/components/authFormUxBehavior.test.ts b/src/ui/components/authFormUxBehavior.test.ts new file mode 100644 index 0000000..2f81383 --- /dev/null +++ b/src/ui/components/authFormUxBehavior.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + CONSENT_CHECKMARK_SYMBOL, + getConsentCheckboxVisualState, + getKeyboardNavigationAccessoryControls, + getKeyboardInputNavigationState, +} from './authFormUxBehavior'; + +describe('auth form UX helpers', () => { + it('finds the previous and next text box around the focused field', () => { + const state = getKeyboardInputNavigationState(['verificationCode', 'firstName', 'lastName'], 'firstName'); + + assert.deepEqual(state, { + activeIndex: 1, + canFocusNext: true, + canFocusPrevious: true, + nextInputId: 'lastName', + positionLabel: '2 of 3', + previousInputId: 'verificationCode', + }); + }); + + it('does not offer movement past the first or last text box', () => { + const first = getKeyboardInputNavigationState(['verificationCode', 'firstName', 'lastName'], 'verificationCode'); + const last = getKeyboardInputNavigationState(['verificationCode', 'firstName', 'lastName'], 'lastName'); + + assert.equal(first.previousInputId, null); + assert.equal(first.canFocusPrevious, false); + assert.equal(first.nextInputId, 'firstName'); + assert.equal(last.previousInputId, 'firstName'); + assert.equal(last.nextInputId, null); + assert.equal(last.canFocusNext, false); + }); + + it('uses icon identifiers for keyboard movement controls without text arrow glyphs or position chrome', () => { + const navigationState = getKeyboardInputNavigationState(['verificationCode', 'firstName', 'lastName'], 'firstName'); + const controls = getKeyboardNavigationAccessoryControls(navigationState); + const serializedControls = JSON.stringify(controls); + + assert.deepEqual(controls, { + done: { + accessibilityLabel: 'Done entering text', + disabled: false, + label: '완료', + }, + next: { + accessibilityLabel: 'Next text box', + disabled: false, + icon: 'keyboard_arrow_down', + }, + previous: { + accessibilityLabel: 'Previous text box', + disabled: false, + icon: 'keyboard_arrow_up', + }, + }); + assert.equal(serializedControls.includes('2 of 3'), false); + assert.equal(Object.prototype.hasOwnProperty.call(controls.previous, 'label'), false); + assert.equal(Object.prototype.hasOwnProperty.call(controls.next, 'label'), false); + assert.equal(serializedControls.includes('^'), false); + assert.equal(serializedControls.includes('⌃'), false); + assert.equal(serializedControls.includes('⌄'), false); + assert.equal(serializedControls.includes('˄'), false); + assert.equal(serializedControls.includes('˅'), false); + assert.equal(serializedControls.includes('['), false); + assert.equal(serializedControls.includes(']'), false); + }); + + it('shows a visible checkmark only when a consent checkbox is checked', () => { + assert.equal(CONSENT_CHECKMARK_SYMBOL, '✓'); + assert.deepEqual(getConsentCheckboxVisualState(true), { + accessibilityState: { checked: true }, + checkmark: '✓', + }); + assert.deepEqual(getConsentCheckboxVisualState(false), { + accessibilityState: { checked: false }, + checkmark: null, + }); + }); +}); diff --git a/src/ui/components/authFormUxBehavior.ts b/src/ui/components/authFormUxBehavior.ts new file mode 100644 index 0000000..f80427f --- /dev/null +++ b/src/ui/components/authFormUxBehavior.ts @@ -0,0 +1,82 @@ +export const CONSENT_CHECKMARK_SYMBOL = '✓'; + +export type KeyboardInputNavigationState = { + activeIndex: number; + canFocusNext: boolean; + canFocusPrevious: boolean; + nextInputId: InputId | null; + positionLabel: string; + previousInputId: InputId | null; +}; + +export type KeyboardNavigationAccessoryIcon = 'keyboard_arrow_down' | 'keyboard_arrow_up'; + +export type KeyboardNavigationAccessoryControls = { + done: { + accessibilityLabel: 'Done entering text'; + disabled: false; + label: '완료'; + }; + next: { + accessibilityLabel: 'Next text box'; + disabled: boolean; + icon: 'keyboard_arrow_down'; + }; + previous: { + accessibilityLabel: 'Previous text box'; + disabled: boolean; + icon: 'keyboard_arrow_up'; + }; +}; + +export function getKeyboardInputNavigationState( + orderedInputIds: readonly InputId[], + activeInputId: InputId | null, +): KeyboardInputNavigationState { + const activeIndex = activeInputId === null ? -1 : orderedInputIds.indexOf(activeInputId); + const previousInputId = activeIndex > 0 ? orderedInputIds[activeIndex - 1] ?? null : null; + const nextInputId = activeIndex >= 0 && activeIndex < orderedInputIds.length - 1 + ? orderedInputIds[activeIndex + 1] ?? null + : null; + + return { + activeIndex, + canFocusNext: nextInputId !== null, + canFocusPrevious: previousInputId !== null, + nextInputId, + positionLabel: activeIndex >= 0 ? `${activeIndex + 1} of ${orderedInputIds.length}` : `0 of ${orderedInputIds.length}`, + previousInputId, + }; +} + +export function getKeyboardNavigationAccessoryControls( + navigationState: KeyboardInputNavigationState, +): KeyboardNavigationAccessoryControls { + return { + done: { + accessibilityLabel: 'Done entering text', + disabled: false, + label: '완료', + }, + next: { + accessibilityLabel: 'Next text box', + disabled: !navigationState.canFocusNext, + icon: 'keyboard_arrow_down', + }, + previous: { + accessibilityLabel: 'Previous text box', + disabled: !navigationState.canFocusPrevious, + icon: 'keyboard_arrow_up', + }, + }; +} + +export function getConsentCheckboxVisualState(checked: boolean): { + accessibilityState: { checked: boolean }; + checkmark: typeof CONSENT_CHECKMARK_SYMBOL | null; +} { + return { + accessibilityState: { checked }, + checkmark: checked ? CONSENT_CHECKMARK_SYMBOL : null, + }; +} diff --git a/src/ui/components/transientToast.test.ts b/src/ui/components/transientToast.test.ts index f4cda30..397f6e1 100644 --- a/src/ui/components/transientToast.test.ts +++ b/src/ui/components/transientToast.test.ts @@ -1,7 +1,15 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { scheduleTransientToastDismiss, TRANSIENT_TOAST_ANDROID_TOP_OFFSET, TRANSIENT_TOAST_BORDER_ALPHA, TRANSIENT_TOAST_DISMISS_DELAY_MS, TRANSIENT_TOAST_SURFACE_ALPHA } from './transientToastBehavior'; +import { + scheduleTransientToastDismiss, + TRANSIENT_TOAST_ANDROID_ELEVATION, + TRANSIENT_TOAST_ANDROID_TOP_OFFSET, + TRANSIENT_TOAST_BORDER_ALPHA, + TRANSIENT_TOAST_DISMISS_DELAY_MS, + TRANSIENT_TOAST_SURFACE_ALPHA, + TRANSIENT_TOAST_Z_INDEX, +} from './transientToastBehavior'; describe('transient toast dismissal', () => { it('dismisses a visible toast after two seconds', () => { @@ -71,6 +79,8 @@ describe('transient toast dismissal', () => { it('uses an Android-safe lower offset and a softer transparent surface', () => { assert.equal(TRANSIENT_TOAST_ANDROID_TOP_OFFSET, 54); + assert.equal(TRANSIENT_TOAST_ANDROID_ELEVATION, 24); + assert.equal(TRANSIENT_TOAST_Z_INDEX, 10_000); assert.equal(TRANSIENT_TOAST_SURFACE_ALPHA, 0.58); assert.equal(TRANSIENT_TOAST_BORDER_ALPHA, 0.42); }); diff --git a/src/ui/components/transientToastBehavior.ts b/src/ui/components/transientToastBehavior.ts index 5b85209..babe722 100644 --- a/src/ui/components/transientToastBehavior.ts +++ b/src/ui/components/transientToastBehavior.ts @@ -1,5 +1,7 @@ export const TRANSIENT_TOAST_DISMISS_DELAY_MS = 2_000; export const TRANSIENT_TOAST_ANDROID_TOP_OFFSET = 54; +export const TRANSIENT_TOAST_ANDROID_ELEVATION = 24; +export const TRANSIENT_TOAST_Z_INDEX = 10_000; export const TRANSIENT_TOAST_BORDER_ALPHA = 0.42; export const TRANSIENT_TOAST_SURFACE_ALPHA = 0.58; diff --git a/tsconfig.json b/tsconfig.json index b9567f6..6c8ca58 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { - "strict": true + "strict": true, + "types": ["node", "react"] } }