diff --git a/.github/workflows/build-demo-app-video.yml b/.github/workflows/build-demo-app-video.yml
new file mode 100644
index 000000000..b6826322b
--- /dev/null
+++ b/.github/workflows/build-demo-app-video.yml
@@ -0,0 +1,25 @@
+name: Build Demo App Video
+run-name: Build Demo App Video On Pull Request
+
+on:
+ pull_request:
+ branches: [main]
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ steps:
+ - name: 💾 Checking out the repository
+ uses: actions/checkout@v4
+ - name: ⚙️ Setting up the MonkJs project
+ uses: ./.github/actions/monkjs-set-up
+ with:
+ build-env: production
+ - name: 📱 Building the demo app video
+ run: cd apps/demo-app-video && yarn build:staging
diff --git a/.github/workflows/deploy-demo-app-video.yml b/.github/workflows/deploy-demo-app-video.yml
new file mode 100644
index 000000000..f34beee69
--- /dev/null
+++ b/.github/workflows/deploy-demo-app-video.yml
@@ -0,0 +1,62 @@
+name: Deploy Demo App Video
+run-name: Deploy Demo App Video To Staging After Merge
+
+on:
+ push:
+ branches: [ main ]
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ permissions:
+ actions: read
+ contents: read
+ security-events: write
+
+ steps:
+ - name: 💾 Checking out the repository
+ uses: actions/checkout@v4
+ - name: ⚙️ Setting up the MonkJs project
+ uses: ./.github/actions/monkjs-set-up
+ with:
+ build-env: production
+ - name: 📱 Building the demo app video
+ run: cd apps/demo-app-video && yarn build:staging
+ - name: 📦 Uploading the artifact
+ uses: actions/upload-artifact@v4.3.1
+ with:
+ name: build-demo-app-video-staging
+ path: apps/demo-app-video/build
+ if-no-files-found: error
+
+ deploy:
+ name: Deploy
+ environment: staging
+ needs:
+ - build
+ container:
+ image: dtzar/helm-kubectl:3.14.2
+ runs-on: ubuntu-latest
+ steps:
+ - name: 🔐 Authenticating to Google Cloud
+ uses: google-github-actions/auth@v2.1.2
+ with:
+ credentials_json: "${{ secrets.GKE_SA_KEY }}"
+ - name: 🔐 Obtaining GKE credentials
+ uses: google-github-actions/get-gke-credentials@v2.1.0
+ with:
+ cluster_name: ${{ secrets.GKE_CLUSTER }}
+ location: ${{ secrets.GKE_ZONE }}
+ project_id: ${{ secrets.GKE_PROJECT }}
+ - name: 📦 Downloading the artifact
+ uses: actions/download-artifact@v4.1.4
+ with:
+ name: build-demo-app-video-staging
+ path: demo-video
+ - name: 🧹 Cleaning up previous build
+ run: |-
+ kubectl -n poc exec -it $(kubectl get pods -n poc -l app.kubernetes.io/instance=poc-spa --no-headers | awk '{print $1}') -- rm -rf demo-video
+ - name: 🌐 Deploying app
+ run: |-
+ kubectl -n poc cp demo-video poc/$(kubectl get pods -n poc -l app.kubernetes.io/instance=poc-spa --no-headers | awk '{print $1}'):/app/
diff --git a/apps/demo-app-video/.env-cmdrc.json b/apps/demo-app-video/.env-cmdrc.json
new file mode 100644
index 000000000..4e850141f
--- /dev/null
+++ b/apps/demo-app-video/.env-cmdrc.json
@@ -0,0 +1,52 @@
+{
+ "local": {
+ "PORT": "17200",
+ "HTTPS": "true",
+ "ESLINT_NO_DEV_ERRORS": "true",
+ "REACT_APP_ENVIRONMENT": "local",
+ "REACT_APP_LIVE_CONFIG_ID": "demo-app-video-development",
+ "REACT_APP_USE_LOCAL_CONFIG": "true",
+ "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai",
+ "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/",
+ "REACT_APP_AUTH_CLIENT_ID": "w9MTl518yqWl0dVE8oUbkxc3gnrI0sgH",
+ "REACT_APP_SENTRY_DSN": "https://e0644a77095a58eeab6b0e32cc9d4188@o4505669501648896.ingest.us.sentry.io/4508575240290304",
+ "REACT_APP_SENTRY_DEBUG": "true",
+ "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai"
+ },
+ "development": {
+ "REACT_APP_ENVIRONMENT": "development",
+ "REACT_APP_LIVE_CONFIG_ID": "demo-app-video-development",
+ "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai",
+ "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/",
+ "REACT_APP_AUTH_CLIENT_ID": "w9MTl518yqWl0dVE8oUbkxc3gnrI0sgH",
+ "REACT_APP_SENTRY_DSN": "https://e0644a77095a58eeab6b0e32cc9d4188@o4505669501648896.ingest.us.sentry.io/4508575240290304",
+ "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai"
+ },
+ "staging": {
+ "REACT_APP_ENVIRONMENT": "staging",
+ "REACT_APP_LIVE_CONFIG_ID": "demo-app-video-staging",
+ "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai",
+ "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/",
+ "REACT_APP_AUTH_CLIENT_ID": "w9MTl518yqWl0dVE8oUbkxc3gnrI0sgH",
+ "REACT_APP_SENTRY_DSN": "https://e0644a77095a58eeab6b0e32cc9d4188@o4505669501648896.ingest.us.sentry.io/4508575240290304",
+ "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai"
+ },
+ "preview": {
+ "REACT_APP_ENVIRONMENT": "preview",
+ "REACT_APP_LIVE_CONFIG_ID": "demo-app-video-preview",
+ "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai",
+ "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/",
+ "REACT_APP_AUTH_CLIENT_ID": "w9MTl518yqWl0dVE8oUbkxc3gnrI0sgH",
+ "REACT_APP_SENTRY_DSN": "https://e0644a77095a58eeab6b0e32cc9d4188@o4505669501648896.ingest.us.sentry.io/4508575240290304",
+ "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai"
+ },
+ "backend-staging-qa": {
+ "REACT_APP_ENVIRONMENT": "backend-staging-qa",
+ "REACT_APP_LIVE_CONFIG_ID": "demo-app-video-backend-staging-qa",
+ "REACT_APP_AUTH_DOMAIN": "idp.staging.monk.ai",
+ "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/",
+ "REACT_APP_AUTH_CLIENT_ID": "PLGfABs0AWNwZaokEg3GeU4m01RhIvyi",
+ "REACT_APP_SENTRY_DSN": "https://e0644a77095a58eeab6b0e32cc9d4188@o4505669501648896.ingest.us.sentry.io/4508575240290304",
+ "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.staging.monk.ai"
+ }
+}
diff --git a/apps/demo-app-video/.eslintignore b/apps/demo-app-video/.eslintignore
new file mode 100644
index 000000000..3c3629e64
--- /dev/null
+++ b/apps/demo-app-video/.eslintignore
@@ -0,0 +1 @@
+node_modules
diff --git a/apps/demo-app-video/.eslintrc.js b/apps/demo-app-video/.eslintrc.js
new file mode 100644
index 000000000..b26896911
--- /dev/null
+++ b/apps/demo-app-video/.eslintrc.js
@@ -0,0 +1,14 @@
+const OFF = 0;
+const WARN = 1;
+const ERROR = 2;
+
+module.exports = {
+ extends: ['@monkvision/eslint-config-typescript-react'],
+ parserOptions: {
+ project: ['./tsconfig.json'],
+ },
+ rules: {
+ 'import/no-extraneous-dependencies': OFF,
+ 'no-console': OFF,
+ }
+}
diff --git a/apps/demo-app-video/.gitignore b/apps/demo-app-video/.gitignore
new file mode 100644
index 000000000..0ec2ddf7c
--- /dev/null
+++ b/apps/demo-app-video/.gitignore
@@ -0,0 +1,26 @@
+# builds
+build/
+lib/
+dist/
+module/
+commonjs/
+typescript/
+web-build/
+
+# modules
+node_modules/
+coverage/
+.expo/
+.docusaurus/
+
+# logs
+npm-debug.*
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# cache
+.eslintcache
+
+# misc
+.DS_Store
diff --git a/apps/demo-app-video/LICENSE b/apps/demo-app-video/LICENSE
new file mode 100644
index 000000000..a3592ab9e
--- /dev/null
+++ b/apps/demo-app-video/LICENSE
@@ -0,0 +1,32 @@
+The Clear BSD License
+
+Copyright (c) [2022] [Monk](http://monk.ai)
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted (subject to the limitations in the disclaimer
+below) provided that the following conditions are met:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ * Neither the name of the copyright holder nor the names of its
+ contributors may be used to endorse or promote products derived from this
+ software without specific prior written permission.
+
+NO EXPRESS OR IMPLIED LICENSES TO ANY PARTY'S PATENT RIGHTS ARE GRANTED BY
+THIS LICENSE. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
+CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
+BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
+IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
diff --git a/apps/demo-app-video/README.md b/apps/demo-app-video/README.md
new file mode 100644
index 000000000..3e259b8a5
--- /dev/null
+++ b/apps/demo-app-video/README.md
@@ -0,0 +1,74 @@
+# Monk Demo App
+This application is a demo app used to showcase how to implement the Monk workflow (authentication, inspection creation,
+inspection capture and inspection report) using the MonkJs SDK.
+
+# Features
+This app contains the following features :
+- Authentication guards to enforce user log in
+- User log in with browser pop-up using Auth0 and token caching in the local storage
+- Automatic creation of a Monk inspection
+- Inspection capture using the PhotoCapture workflow
+- Redirection to the Monk inspection report app (since the inspection report component is not yet available in MonkJs
+ 4.0)
+- Possiblity of passing the following configuration in the URL search params :
+ - Encrypted authentication token using ZLib (the user does not have to log in)
+ - Inspection ID (instead of creating a new one automatically)
+ - Vehicle type used for the Sights (default one is CUV)
+ - Application language (English / French / German / Dutch)
+
+# Running the App
+In order to run the app, you will need to have [NodeJs](https://nodejs.org/en) >= 16 and
+[Yarn 3](https://yarnpkg.com/getting-started/install) installed. Then, you'll need to install the required dependencies
+using the following command :
+
+```bash
+yarn install
+```
+
+You then need to copy the local environment configuration available in the `env.txt` file at the root of the directory
+into an env file called `.env` :
+
+```bash
+cp env.txt .env
+```
+
+You can then start the app by running :
+
+```bash
+yarn start
+```
+
+The application is by default available at `https://localhost:17200/`.
+
+# Building the App
+To build the app, you simply need to run the following command :
+
+```bash
+yarn build
+```
+
+Don't forget to update the environment variables defined in your `.env` file for the target website.
+
+# Testing
+## Running the Tests
+To run the tests of the app, simply run the following command :
+
+```bash
+yarn test
+```
+
+To run the tests as well as collecgt coverage, run the following command :
+
+```bash
+yarn test:coverage
+```
+
+## Analyzing Bundle Size
+After building the app using the `yarn build` command, you can analyze the bundle size using the following command :
+
+```bash
+yarn analyze
+```
+
+This will open a new window on your desktop browser where you'll be able to see the sizes of each module in the final
+app.
diff --git a/apps/demo-app-video/jest.config.js b/apps/demo-app-video/jest.config.js
new file mode 100644
index 000000000..518bba263
--- /dev/null
+++ b/apps/demo-app-video/jest.config.js
@@ -0,0 +1,13 @@
+const { react } = require('@monkvision/jest-config');
+
+module.exports = {
+ ...react({ monorepo: true }),
+ coverageThreshold: {
+ global: {
+ branches: 0,
+ functions: 0,
+ lines: 0,
+ statements: 0,
+ },
+ },
+};
diff --git a/apps/demo-app-video/package.json b/apps/demo-app-video/package.json
new file mode 100644
index 000000000..98414c1da
--- /dev/null
+++ b/apps/demo-app-video/package.json
@@ -0,0 +1,109 @@
+{
+ "name": "monk-demo-app-video",
+ "version": "4.5.6",
+ "license": "BSD-3-Clause-Clear",
+ "packageManager": "yarn@3.2.4",
+ "description": "MonkJs demo app for Video capture with React and TypeScript",
+ "author": "monkvision",
+ "private": true,
+ "scripts": {
+ "start": "env-cmd -e local react-scripts start",
+ "build:development": "env-cmd -e development react-scripts build",
+ "build:staging": "env-cmd -e staging react-scripts build",
+ "build:preview": "env-cmd -e preview react-scripts build",
+ "build:backend-staging-qa": "env-cmd -e backend-staging-qa react-scripts build",
+ "test": "jest --passWithNoTests",
+ "test:coverage": "jest --coverage --passWithNoTests",
+ "analyze": "source-map-explorer 'build/static/js/*.js'",
+ "eject": "react-scripts eject",
+ "prettier": "prettier --check ./src",
+ "prettier:fix": "prettier --write ./src",
+ "eslint": "eslint --format=pretty ./src",
+ "eslint:fix": "eslint --fix --format=pretty ./src",
+ "lint": "yarn run prettier && yarn run eslint",
+ "lint:fix": "yarn run prettier:fix && yarn run eslint:fix"
+ },
+ "dependencies": {
+ "@auth0/auth0-react": "^2.2.4",
+ "@monkvision/analytics": "4.5.6",
+ "@monkvision/common": "4.5.6",
+ "@monkvision/common-ui-web": "4.5.6",
+ "@monkvision/inspection-capture-web": "4.5.6",
+ "@monkvision/monitoring": "4.5.6",
+ "@monkvision/network": "4.5.6",
+ "@monkvision/posthog": "4.5.6",
+ "@monkvision/sentry": "4.5.6",
+ "@monkvision/sights": "4.5.6",
+ "@monkvision/types": "4.5.6",
+ "@types/babel__core": "^7",
+ "@types/jest": "^27.5.2",
+ "@types/node": "^16.18.18",
+ "@types/react": "^17.0.2",
+ "@types/react-dom": "^17.0.2",
+ "@types/react-router-dom": "^5.3.3",
+ "@types/sort-by": "^1",
+ "axios": "^1.5.0",
+ "i18next": "^23.4.5",
+ "i18next-browser-languagedetector": "^7.1.0",
+ "jest-watch-typeahead": "^2.2.2",
+ "localforage": "^1.10.0",
+ "match-sorter": "^6.3.4",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2",
+ "react-i18next": "^13.2.0",
+ "react-router-dom": "^6.22.3",
+ "react-scripts": "5.0.1",
+ "sort-by": "^1.2.0",
+ "source-map-explorer": "^2.5.3",
+ "typescript": "^4.9.5",
+ "web-vitals": "^2.1.4"
+ },
+ "devDependencies": {
+ "@babel/core": "^7.22.9",
+ "@monkvision/eslint-config-base": "4.5.6",
+ "@monkvision/eslint-config-typescript": "4.5.6",
+ "@monkvision/eslint-config-typescript-react": "4.5.6",
+ "@monkvision/jest-config": "4.5.6",
+ "@monkvision/prettier-config": "4.5.6",
+ "@monkvision/test-utils": "4.5.6",
+ "@monkvision/typescript-config": "4.5.6",
+ "@testing-library/dom": "^8.20.0",
+ "@testing-library/jest-dom": "^5.16.5",
+ "@testing-library/react": "^12.1.5",
+ "@testing-library/react-hooks": "^8.0.1",
+ "@testing-library/user-event": "^12.1.5",
+ "@typescript-eslint/eslint-plugin": "^5.43.0",
+ "@typescript-eslint/parser": "^5.43.0",
+ "env-cmd": "^10.1.0",
+ "eslint": "^8.29.0",
+ "eslint-config-airbnb-base": "^15.0.0",
+ "eslint-config-prettier": "^8.5.0",
+ "eslint-formatter-pretty": "^4.1.0",
+ "eslint-plugin-eslint-comments": "^3.2.0",
+ "eslint-plugin-import": "^2.26.0",
+ "eslint-plugin-jest": "^25.3.0",
+ "eslint-plugin-jsx-a11y": "^6.7.1",
+ "eslint-plugin-prettier": "^4.2.1",
+ "eslint-plugin-promise": "^6.1.1",
+ "eslint-plugin-react": "^7.27.1",
+ "eslint-plugin-react-hooks": "^4.3.0",
+ "eslint-utils": "^3.0.0",
+ "jest": "^29.3.1",
+ "prettier": "^2.7.1",
+ "regexpp": "^3.2.0",
+ "ts-jest": "^29.0.3"
+ },
+ "prettier": "@monkvision/prettier-config",
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/apps/demo-app-video/public/favicon.ico b/apps/demo-app-video/public/favicon.ico
new file mode 100644
index 000000000..6e6a629b0
Binary files /dev/null and b/apps/demo-app-video/public/favicon.ico differ
diff --git a/apps/demo-app-video/public/index.html b/apps/demo-app-video/public/index.html
new file mode 100644
index 000000000..0c27df4db
--- /dev/null
+++ b/apps/demo-app-video/public/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+ Vehicle Inspection
+
+
+
+
+
+
diff --git a/apps/demo-app-video/public/logo192.png b/apps/demo-app-video/public/logo192.png
new file mode 100644
index 000000000..0690e33a3
Binary files /dev/null and b/apps/demo-app-video/public/logo192.png differ
diff --git a/apps/demo-app-video/public/logo512.png b/apps/demo-app-video/public/logo512.png
new file mode 100644
index 000000000..03813dd12
Binary files /dev/null and b/apps/demo-app-video/public/logo512.png differ
diff --git a/apps/demo-app-video/public/manifest.json b/apps/demo-app-video/public/manifest.json
new file mode 100644
index 000000000..5634c25b3
--- /dev/null
+++ b/apps/demo-app-video/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "Monk Demo App",
+ "name": "Monk Inspection Demo Application",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#274B9F",
+ "background_color": "#202020"
+}
diff --git a/apps/demo-app-video/public/robots.txt b/apps/demo-app-video/public/robots.txt
new file mode 100644
index 000000000..e9e57dc4d
--- /dev/null
+++ b/apps/demo-app-video/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/apps/demo-app-video/src/components/App.tsx b/apps/demo-app-video/src/components/App.tsx
new file mode 100644
index 000000000..b86bf83c1
--- /dev/null
+++ b/apps/demo-app-video/src/components/App.tsx
@@ -0,0 +1,34 @@
+import { Outlet, useNavigate } from 'react-router-dom';
+import { getEnvOrThrow, MonkProvider } from '@monkvision/common';
+import { useTranslation } from 'react-i18next';
+import { LiveConfigAppProvider } from '@monkvision/common-ui-web';
+import { LiveConfig } from '@monkvision/types';
+import { Page } from '../pages';
+import * as config from '../local-config.json';
+import { AppContainer } from './AppContainer';
+
+const localConfig =
+ process.env['REACT_APP_USE_LOCAL_CONFIG'] === 'true'
+ ? (config as unknown as LiveConfig)
+ : undefined;
+
+export function App() {
+ const navigate = useNavigate();
+ const { i18n } = useTranslation();
+
+ return (
+ navigate(Page.CREATE_INSPECTION)}
+ onFetchLanguage={(lang) => i18n.changeLanguage(lang)}
+ lang={i18n.language}
+ >
+
+
+
+
+
+
+ );
+}
diff --git a/apps/demo-app-video/src/components/AppContainer.tsx b/apps/demo-app-video/src/components/AppContainer.tsx
new file mode 100644
index 000000000..2f56046c5
--- /dev/null
+++ b/apps/demo-app-video/src/components/AppContainer.tsx
@@ -0,0 +1,22 @@
+import { PropsWithChildren } from 'react';
+import { MonkThemeProvider, useMonkAppState, useMonkTheme } from '@monkvision/common';
+
+function RootStylesContainer({ children }: PropsWithChildren) {
+ const { rootStyles } = useMonkTheme();
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function AppContainer({ children }: PropsWithChildren) {
+ const { config } = useMonkAppState();
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/demo-app-video/src/components/AppRouter.tsx b/apps/demo-app-video/src/components/AppRouter.tsx
new file mode 100644
index 000000000..f4e88010a
--- /dev/null
+++ b/apps/demo-app-video/src/components/AppRouter.tsx
@@ -0,0 +1,43 @@
+import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom';
+import { AuthGuard } from '@monkvision/common-ui-web';
+import {
+ CreateInspectionPage,
+ InspectionCompletePage,
+ LoginPage,
+ Page,
+ VideoCapturePage,
+} from '../pages';
+import { App } from './App';
+
+export function AppRouter() {
+ return (
+
+
+ }>
+ } />
+ } />
+ } />
+
+
+
+ }
+ index
+ />
+
+
+
+ }
+ index
+ />
+ } />
+
+
+
+ );
+}
diff --git a/apps/demo-app-video/src/components/index.ts b/apps/demo-app-video/src/components/index.ts
new file mode 100644
index 000000000..724f3116e
--- /dev/null
+++ b/apps/demo-app-video/src/components/index.ts
@@ -0,0 +1,3 @@
+export * from './App';
+export * from './AppRouter';
+export * from './AppContainer';
diff --git a/apps/demo-app-video/src/i18n.ts b/apps/demo-app-video/src/i18n.ts
new file mode 100644
index 000000000..be8ddfaeb
--- /dev/null
+++ b/apps/demo-app-video/src/i18n.ts
@@ -0,0 +1,28 @@
+import i18n from 'i18next';
+import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector';
+import { initReactI18next } from 'react-i18next';
+import { monkLanguages } from '@monkvision/types';
+import en from './translations/en.json';
+import fr from './translations/fr.json';
+import de from './translations/de.json';
+import nl from './translations/nl.json';
+
+i18n
+ .use(I18nextBrowserLanguageDetector)
+ .use(initReactI18next)
+ .init({
+ compatibilityJSON: 'v3',
+ fallbackLng: 'en',
+ interpolation: { escapeValue: false },
+ supportedLngs: monkLanguages,
+ nonExplicitSupportedLngs: true,
+ resources: {
+ en: { translation: en },
+ fr: { translation: fr },
+ de: { translation: de },
+ nl: { translation: nl },
+ },
+ })
+ .catch(console.error);
+
+export default i18n;
diff --git a/apps/demo-app-video/src/index.css b/apps/demo-app-video/src/index.css
new file mode 100644
index 000000000..404c8fcb4
--- /dev/null
+++ b/apps/demo-app-video/src/index.css
@@ -0,0 +1,16 @@
+html,
+body,
+#root,
+.app-container {
+ height: 100dvh;
+ width: 100%;
+ text-size-adjust: 100%;
+ -webkit-text-size-adjust: 100%;
+}
+
+body {
+ margin: 0;
+ background-color: #000000;
+ font-family: sans-serif;
+ color: white;
+}
diff --git a/apps/demo-app-video/src/index.tsx b/apps/demo-app-video/src/index.tsx
new file mode 100644
index 000000000..9a5832da1
--- /dev/null
+++ b/apps/demo-app-video/src/index.tsx
@@ -0,0 +1,29 @@
+import ReactDOM from 'react-dom';
+import { MonitoringProvider } from '@monkvision/monitoring';
+import { AnalyticsProvider } from '@monkvision/analytics';
+import { Auth0Provider } from '@auth0/auth0-react';
+import { getEnvOrThrow } from '@monkvision/common';
+import { sentryMonitoringAdapter } from './sentry';
+import { posthogAnalyticsAdapter } from './posthog';
+import { AppRouter } from './components';
+import './index.css';
+import './i18n';
+
+ReactDOM.render(
+
+
+
+
+
+
+ ,
+ document.getElementById('root'),
+);
diff --git a/apps/demo-app-video/src/local-config.json b/apps/demo-app-video/src/local-config.json
new file mode 100644
index 000000000..70228dee8
--- /dev/null
+++ b/apps/demo-app-video/src/local-config.json
@@ -0,0 +1,34 @@
+{
+ "id": "demo-app-video-local",
+ "description": "Config for the local Video Demo App.",
+ "workflow": "video",
+ "allowManualLogin": true,
+ "fetchFromSearchParams": true,
+ "allowCreateInspection": true,
+ "createInspectionOptions": {
+ "tasks": ["damage_detection"],
+ "isVideoCapture": true
+ },
+ "apiDomain": "api.preview.monk.ai/v1",
+ "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize",
+ "startTasksOnComplete": true,
+ "enforceOrientation": "portrait",
+ "minRecordingDuration": 15000,
+ "maxRetryCount": 3,
+ "maxUploadDurationWarning": 15000,
+ "useAdaptiveImageQuality": true,
+ "format": "image/jpeg",
+ "quality": 0.6,
+ "resolution": "4K",
+ "allowImageUpscaling": false,
+ "requiredApiPermissions": [
+ "monk_core_api:compliances",
+ "monk_core_api:damage_detection",
+ "monk_core_api:images_ocr",
+ "monk_core_api:wheel_analysis",
+ "monk_core_api:inspections:create",
+ "monk_core_api:inspections:read",
+ "monk_core_api:inspections:update",
+ "monk_core_api:inspections:write"
+ ]
+}
diff --git a/apps/demo-app-video/src/pages/CreateInspectionPage/CreateInspectionPage.module.css b/apps/demo-app-video/src/pages/CreateInspectionPage/CreateInspectionPage.module.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/demo-app-video/src/pages/CreateInspectionPage/CreateInspectionPage.tsx b/apps/demo-app-video/src/pages/CreateInspectionPage/CreateInspectionPage.tsx
new file mode 100644
index 000000000..2f8763f99
--- /dev/null
+++ b/apps/demo-app-video/src/pages/CreateInspectionPage/CreateInspectionPage.tsx
@@ -0,0 +1,16 @@
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+import { CreateInspection } from '@monkvision/common-ui-web';
+import { Page } from '../pages';
+
+export function CreateInspectionPage() {
+ const navigate = useNavigate();
+ const { i18n } = useTranslation();
+
+ return (
+ navigate(Page.VIDEO_CAPTURE)}
+ lang={i18n.language}
+ />
+ );
+}
diff --git a/apps/demo-app-video/src/pages/CreateInspectionPage/index.ts b/apps/demo-app-video/src/pages/CreateInspectionPage/index.ts
new file mode 100644
index 000000000..cd6c862e8
--- /dev/null
+++ b/apps/demo-app-video/src/pages/CreateInspectionPage/index.ts
@@ -0,0 +1 @@
+export * from './CreateInspectionPage';
diff --git a/apps/demo-app-video/src/pages/InspectionCompletePage/InspectionCompletePage.module.css b/apps/demo-app-video/src/pages/InspectionCompletePage/InspectionCompletePage.module.css
new file mode 100644
index 000000000..388f806e2
--- /dev/null
+++ b/apps/demo-app-video/src/pages/InspectionCompletePage/InspectionCompletePage.module.css
@@ -0,0 +1,12 @@
+.container {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ font-size: 20px;
+ text-align: center;
+ padding: 20px;
+ box-sizing: border-box;
+}
diff --git a/apps/demo-app-video/src/pages/InspectionCompletePage/InspectionCompletePage.tsx b/apps/demo-app-video/src/pages/InspectionCompletePage/InspectionCompletePage.tsx
new file mode 100644
index 000000000..aeebf187e
--- /dev/null
+++ b/apps/demo-app-video/src/pages/InspectionCompletePage/InspectionCompletePage.tsx
@@ -0,0 +1,8 @@
+import { useTranslation } from 'react-i18next';
+import styles from './InspectionCompletePage.module.css';
+
+export function InspectionCompletePage() {
+ const { t } = useTranslation();
+
+ return {t('inspection-complete.thank-message')}
;
+}
diff --git a/apps/demo-app-video/src/pages/InspectionCompletePage/index.ts b/apps/demo-app-video/src/pages/InspectionCompletePage/index.ts
new file mode 100644
index 000000000..8371ececb
--- /dev/null
+++ b/apps/demo-app-video/src/pages/InspectionCompletePage/index.ts
@@ -0,0 +1 @@
+export * from './InspectionCompletePage';
diff --git a/apps/demo-app-video/src/pages/LoginPage/LoginPage.module.css b/apps/demo-app-video/src/pages/LoginPage/LoginPage.module.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/apps/demo-app-video/src/pages/LoginPage/LoginPage.tsx b/apps/demo-app-video/src/pages/LoginPage/LoginPage.tsx
new file mode 100644
index 000000000..3dd42f596
--- /dev/null
+++ b/apps/demo-app-video/src/pages/LoginPage/LoginPage.tsx
@@ -0,0 +1,11 @@
+import { useNavigate } from 'react-router-dom';
+import { Login } from '@monkvision/common-ui-web';
+import { useTranslation } from 'react-i18next';
+import { Page } from '../pages';
+
+export function LoginPage() {
+ const { i18n } = useTranslation();
+ const navigate = useNavigate();
+
+ return navigate(Page.CREATE_INSPECTION)} />;
+}
diff --git a/apps/demo-app-video/src/pages/LoginPage/index.ts b/apps/demo-app-video/src/pages/LoginPage/index.ts
new file mode 100644
index 000000000..f772190eb
--- /dev/null
+++ b/apps/demo-app-video/src/pages/LoginPage/index.ts
@@ -0,0 +1 @@
+export * from './LoginPage';
diff --git a/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.module.css b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.module.css
new file mode 100644
index 000000000..8b5fde5ec
--- /dev/null
+++ b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.module.css
@@ -0,0 +1,12 @@
+.container {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.error-message {
+ text-align: center;
+ padding-bottom: 20px;
+}
diff --git a/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx
new file mode 100644
index 000000000..634664d04
--- /dev/null
+++ b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx
@@ -0,0 +1,34 @@
+import { useTranslation } from 'react-i18next';
+import { VideoCapture } from '@monkvision/inspection-capture-web';
+import { useMonkAppState } from '@monkvision/common';
+import { CaptureWorkflow } from '@monkvision/types';
+import styles from './VideoCapturePage.module.css';
+import { createInspectionReportLink } from './inspectionReport';
+
+export function VideoCapturePage() {
+ const { i18n } = useTranslation();
+ const { config, authToken, inspectionId } = useMonkAppState({
+ requireInspection: true,
+ requireWorkflow: CaptureWorkflow.VIDEO,
+ });
+
+ const handleComplete = () => {
+ window.location.href = createInspectionReportLink(authToken, inspectionId, i18n.language);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/demo-app-video/src/pages/VideoCapturePage/index.ts b/apps/demo-app-video/src/pages/VideoCapturePage/index.ts
new file mode 100644
index 000000000..061d5e6dd
--- /dev/null
+++ b/apps/demo-app-video/src/pages/VideoCapturePage/index.ts
@@ -0,0 +1 @@
+export * from './VideoCapturePage';
diff --git a/apps/demo-app-video/src/pages/VideoCapturePage/inspectionReport.ts b/apps/demo-app-video/src/pages/VideoCapturePage/inspectionReport.ts
new file mode 100644
index 000000000..4023e0bea
--- /dev/null
+++ b/apps/demo-app-video/src/pages/VideoCapturePage/inspectionReport.ts
@@ -0,0 +1,11 @@
+import { getEnvOrThrow, zlibCompress } from '@monkvision/common';
+
+export function createInspectionReportLink(
+ authToken: string | null,
+ inspectionId: string | null,
+ language: string,
+): string {
+ const url = getEnvOrThrow('REACT_APP_INSPECTION_REPORT_URL');
+ const token = encodeURIComponent(zlibCompress(authToken ?? ''));
+ return `${url}?c=e5j&lang=${language}&i=${inspectionId}&t=${token}`;
+}
diff --git a/apps/demo-app-video/src/pages/index.ts b/apps/demo-app-video/src/pages/index.ts
new file mode 100644
index 000000000..29940989d
--- /dev/null
+++ b/apps/demo-app-video/src/pages/index.ts
@@ -0,0 +1,5 @@
+export * from './pages';
+export * from './LoginPage';
+export * from './CreateInspectionPage';
+export * from './VideoCapturePage';
+export * from './InspectionCompletePage';
diff --git a/apps/demo-app-video/src/pages/pages.ts b/apps/demo-app-video/src/pages/pages.ts
new file mode 100644
index 000000000..5496c56e8
--- /dev/null
+++ b/apps/demo-app-video/src/pages/pages.ts
@@ -0,0 +1,6 @@
+export enum Page {
+ LOG_IN = '/log-in',
+ CREATE_INSPECTION = '/create-inspection',
+ VIDEO_CAPTURE = '/video-capture',
+ INSPECTION_COMPLETE = '/inspection-complete',
+}
diff --git a/apps/demo-app-video/src/posthog.ts b/apps/demo-app-video/src/posthog.ts
new file mode 100644
index 000000000..b10d03bbd
--- /dev/null
+++ b/apps/demo-app-video/src/posthog.ts
@@ -0,0 +1,10 @@
+import { PosthogAnalyticsAdapter } from '@monkvision/posthog';
+import { getEnvOrThrow } from '@monkvision/common';
+
+export const posthogAnalyticsAdapter = new PosthogAnalyticsAdapter({
+ token: 'phc_9mKWu5rYzvrUT6Bo3bTzrclNa5sOILKthH9BA9sna0M',
+ api_host: 'https://eu.posthog.com',
+ environnement: getEnvOrThrow('REACT_APP_ENVIRONMENT'),
+ projectName: 'demo-app',
+ release: '1.0.0',
+});
diff --git a/apps/demo-app-video/src/react-app-env.d.ts b/apps/demo-app-video/src/react-app-env.d.ts
new file mode 100644
index 000000000..6431bc5fc
--- /dev/null
+++ b/apps/demo-app-video/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/apps/demo-app-video/src/sentry.ts b/apps/demo-app-video/src/sentry.ts
new file mode 100644
index 000000000..ac97afe24
--- /dev/null
+++ b/apps/demo-app-video/src/sentry.ts
@@ -0,0 +1,10 @@
+import { SentryMonitoringAdapter } from '@monkvision/sentry';
+import { getEnvOrThrow } from '@monkvision/common';
+
+export const sentryMonitoringAdapter = new SentryMonitoringAdapter({
+ dsn: getEnvOrThrow('REACT_APP_SENTRY_DSN'),
+ environment: getEnvOrThrow('REACT_APP_ENVIRONMENT'),
+ debug: process.env['REACT_APP_SENTRY_DEBUG'] === 'true',
+ tracesSampleRate: 0.025,
+ release: '1.0',
+});
diff --git a/apps/demo-app-video/src/setupTests.ts b/apps/demo-app-video/src/setupTests.ts
new file mode 100644
index 000000000..8f2609b7b
--- /dev/null
+++ b/apps/demo-app-video/src/setupTests.ts
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
diff --git a/apps/demo-app-video/src/translations/de.json b/apps/demo-app-video/src/translations/de.json
new file mode 100644
index 000000000..e5017015e
--- /dev/null
+++ b/apps/demo-app-video/src/translations/de.json
@@ -0,0 +1,5 @@
+{
+ "inspection-complete": {
+ "thank-message": "Vielen Dank, dass Sie sich die Zeit genommen haben, die Inspektion durchzuführen."
+ }
+}
diff --git a/apps/demo-app-video/src/translations/en.json b/apps/demo-app-video/src/translations/en.json
new file mode 100644
index 000000000..ed563f756
--- /dev/null
+++ b/apps/demo-app-video/src/translations/en.json
@@ -0,0 +1,5 @@
+{
+ "inspection-complete": {
+ "thank-message": "Thank you for taking the time to complete the inspection."
+ }
+}
diff --git a/apps/demo-app-video/src/translations/fr.json b/apps/demo-app-video/src/translations/fr.json
new file mode 100644
index 000000000..c6c4b3bf1
--- /dev/null
+++ b/apps/demo-app-video/src/translations/fr.json
@@ -0,0 +1,5 @@
+{
+ "inspection-complete": {
+ "thank-message": "Merci d'avoir pris le temps pour compléter l'inspection."
+ }
+}
diff --git a/apps/demo-app-video/src/translations/nl.json b/apps/demo-app-video/src/translations/nl.json
new file mode 100644
index 000000000..233b8b728
--- /dev/null
+++ b/apps/demo-app-video/src/translations/nl.json
@@ -0,0 +1,5 @@
+{
+ "inspection-complete": {
+ "thank-message": "Bedankt voor het nemen van de tijd om de inspectie te voltooien."
+ }
+}
diff --git a/apps/demo-app-video/tsconfig.build.json b/apps/demo-app-video/tsconfig.build.json
new file mode 100644
index 000000000..73e2cff13
--- /dev/null
+++ b/apps/demo-app-video/tsconfig.build.json
@@ -0,0 +1,5 @@
+{
+ "extends": "./tsconfig.json",
+ "include": ["src"],
+ "exclude": ["test"]
+}
diff --git a/apps/demo-app-video/tsconfig.json b/apps/demo-app-video/tsconfig.json
new file mode 100644
index 000000000..60b0893a5
--- /dev/null
+++ b/apps/demo-app-video/tsconfig.json
@@ -0,0 +1,4 @@
+{
+ "extends": "@monkvision/typescript-config/tsconfig.react.json",
+ "include": ["src", "test"]
+}
diff --git a/apps/demo-app/package.json b/apps/demo-app/package.json
index 4a6cb2313..a5efd19b1 100644
--- a/apps/demo-app/package.json
+++ b/apps/demo-app/package.json
@@ -3,7 +3,7 @@
"version": "4.5.6",
"license": "BSD-3-Clause-Clear",
"packageManager": "yarn@3.2.4",
- "description": "MonkJs test app with react and typescript",
+ "description": "MonkJs demo app for Photo capture with React and TypeScript",
"author": "monkvision",
"private": true,
"scripts": {
diff --git a/apps/demo-app/src/components/App.tsx b/apps/demo-app/src/components/App.tsx
index 763f3573d..b86bf83c1 100644
--- a/apps/demo-app/src/components/App.tsx
+++ b/apps/demo-app/src/components/App.tsx
@@ -2,13 +2,15 @@ import { Outlet, useNavigate } from 'react-router-dom';
import { getEnvOrThrow, MonkProvider } from '@monkvision/common';
import { useTranslation } from 'react-i18next';
import { LiveConfigAppProvider } from '@monkvision/common-ui-web';
-import { CaptureAppConfig } from '@monkvision/types';
+import { LiveConfig } from '@monkvision/types';
import { Page } from '../pages';
import * as config from '../local-config.json';
import { AppContainer } from './AppContainer';
const localConfig =
- process.env['REACT_APP_USE_LOCAL_CONFIG'] === 'true' ? (config as CaptureAppConfig) : undefined;
+ process.env['REACT_APP_USE_LOCAL_CONFIG'] === 'true'
+ ? (config as unknown as LiveConfig)
+ : undefined;
export function App() {
const navigate = useNavigate();
diff --git a/apps/demo-app/src/local-config.json b/apps/demo-app/src/local-config.json
index a0996525e..c3033a559 100644
--- a/apps/demo-app/src/local-config.json
+++ b/apps/demo-app/src/local-config.json
@@ -1,6 +1,7 @@
{
- "id": "demo-app-dev",
- "description": "Config for the dev Demo App.",
+ "id": "demo-app-local",
+ "description": "Config for the local Demo App.",
+ "workflow": "photo",
"allowSkipRetake": true,
"enableAddDamage": true,
"enableSightGuidelines": true,
diff --git a/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx
index c6f5f6443..10248471e 100644
--- a/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx
+++ b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useMonkAppState } from '@monkvision/common';
import { PhotoCapture } from '@monkvision/inspection-capture-web';
+import { CaptureWorkflow } from '@monkvision/types';
import styles from './PhotoCapturePage.module.css';
import { createInspectionReportLink } from './inspectionReport';
@@ -9,6 +10,7 @@ export function PhotoCapturePage() {
const { i18n } = useTranslation();
const { config, authToken, inspectionId, vehicleType, getCurrentSights } = useMonkAppState({
requireInspection: true,
+ requireWorkflow: CaptureWorkflow.PHOTO,
});
const currentSights = useMemo(() => getCurrentSights(), [getCurrentSights]);
diff --git a/apps/demo-app/src/pages/VehicleTypeSelectionPage/VehicleTypeSelectionPage.tsx b/apps/demo-app/src/pages/VehicleTypeSelectionPage/VehicleTypeSelectionPage.tsx
index 90b2e8414..bd8152e00 100644
--- a/apps/demo-app/src/pages/VehicleTypeSelectionPage/VehicleTypeSelectionPage.tsx
+++ b/apps/demo-app/src/pages/VehicleTypeSelectionPage/VehicleTypeSelectionPage.tsx
@@ -2,10 +2,13 @@ import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import { VehicleTypeSelection } from '@monkvision/common-ui-web';
import { useMonkAppState } from '@monkvision/common';
+import { CaptureWorkflow } from '@monkvision/types';
import { Page } from '../pages';
export function VehicleTypeSelectionPage() {
- const { config, vehicleType, authToken, inspectionId, setVehicleType } = useMonkAppState();
+ const { config, vehicleType, authToken, inspectionId, setVehicleType } = useMonkAppState({
+ requireWorkflow: CaptureWorkflow.PHOTO,
+ });
const { i18n } = useTranslation();
if (vehicleType || !config.allowVehicleTypeSelection) {
diff --git a/configs/test-utils/src/__mocks__/@monkvision/camera-web.tsx b/configs/test-utils/src/__mocks__/@monkvision/camera-web.tsx
index b9d34112b..68e927401 100644
--- a/configs/test-utils/src/__mocks__/@monkvision/camera-web.tsx
+++ b/configs/test-utils/src/__mocks__/@monkvision/camera-web.tsx
@@ -10,4 +10,7 @@ export = {
SimpleCameraHUD: jest.fn(() => <>>),
i18nCamera: {},
getCameraErrorLabel: jest.fn(() => ({ en: '', fr: '', de: '' })),
+ useCameraPermission: jest.fn(() => ({
+ requestCameraPermission: jest.fn(() => Promise.resolve()),
+ })),
};
diff --git a/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx b/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx
index 23c141138..3f6b0c932 100644
--- a/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx
+++ b/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx
@@ -5,17 +5,28 @@ export = {
iconNames,
/* Mocks */
+ AuthGuard: jest.fn(() => <>>),
BackdropDialog: jest.fn(() => <>>),
Button: jest.fn(() => <>>),
+ Checkbox: jest.fn(() => <>>),
+ CreateInspection: jest.fn(() => <>>),
DynamicSVG: jest.fn(() => <>>),
Icon: jest.fn(() => <>>),
ImageDetailedView: jest.fn(() => <>>),
InspectionGallery: jest.fn(() => <>>),
+ LiveConfigAppProvider: jest.fn(() => <>>),
Login: jest.fn(() => <>>),
+ RecordVideoButton: jest.fn(() => <>>),
SightOverlay: jest.fn(() => <>>),
Slider: jest.fn(() => <>>),
Spinner: jest.fn(() => <>>),
SVGElement: jest.fn(() => <>>),
SwitchButton: jest.fn(() => <>>),
TakePictureButton: jest.fn(() => <>>),
+ TextField: jest.fn(() => <>>),
+ VehicleDynamicWireframe: jest.fn(() => <>>),
+ VehiclePartSelection: jest.fn(() => <>>),
+ VehicleTypeAsset: jest.fn(() => <>>),
+ VehicleTypeSelection: jest.fn(() => <>>),
+ VehicleWalkaroundIndicator: jest.fn(() => <>>),
};
diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx
index 72b341851..b0732b070 100644
--- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx
+++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx
@@ -137,4 +137,10 @@ export = {
isInputTouchedOrDirty: jest.fn(() => false),
})),
useIsMounted: jest.fn(() => jest.fn(() => true)),
+ fullyColorSVG: jest.fn(() => ({})),
+ useDeviceOrientation: jest.fn(() => ({
+ isPermissionGranted: false,
+ alpha: 0,
+ requestCompassPermission: jest.fn(() => Promise.resolve()),
+ })),
};
diff --git a/configs/test-utils/src/__mocks__/@monkvision/network.ts b/configs/test-utils/src/__mocks__/@monkvision/network.ts
index d81cd2425..bbb35ecec 100644
--- a/configs/test-utils/src/__mocks__/@monkvision/network.ts
+++ b/configs/test-utils/src/__mocks__/@monkvision/network.ts
@@ -3,11 +3,22 @@ const { MonkApiPermission, MonkNetworkError, ImageUploadType } =
const MonkApi = {
getInspection: jest.fn(() => Promise.resolve()),
+ getAllInspections: jest.fn(() => Promise.resolve()),
+ getAllInspectionsCount: jest.fn(() => Promise.resolve()),
createInspection: jest.fn(() => Promise.resolve()),
addImage: jest.fn(() => Promise.resolve()),
updateTaskStatus: jest.fn(() => Promise.resolve()),
startInspectionTasks: jest.fn(() => Promise.resolve()),
getLiveConfig: jest.fn(() => Promise.resolve({})),
+ updateInspectionVehicle: jest.fn(() => Promise.resolve()),
+ updateAdditionalData: jest.fn(() => Promise.resolve()),
+ createPricing: jest.fn(() => Promise.resolve()),
+ deletePricing: jest.fn(() => Promise.resolve()),
+ updatePricing: jest.fn(() => Promise.resolve()),
+ createDamage: jest.fn(() => Promise.resolve()),
+ deleteDamage: jest.fn(() => Promise.resolve()),
+ uploadPdf: jest.fn(() => Promise.resolve()),
+ getPdf: jest.fn(() => Promise.resolve()),
};
export = {
diff --git a/documentation/docs/analytics.md b/documentation/docs/analytics.md
index c9c1e7f0a..37934b20d 100644
--- a/documentation/docs/analytics.md
+++ b/documentation/docs/analytics.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 8
+sidebar_position: 9
---
# Analytics
diff --git a/documentation/docs/application-state.md b/documentation/docs/application-state.md
index 959dc4a58..8a7154b3c 100644
--- a/documentation/docs/application-state.md
+++ b/documentation/docs/application-state.md
@@ -18,7 +18,7 @@ containing the following properties:
| Name | Type | Description |
|------------------|-----------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------|
| loading | `LoadingState` | Loading state indicating if the app state provider is loading. |
-| config | `CaptureAppConfig` | The current application configuration. |
+| config | `PhotoCaptureAppConfig` | The current application configuration. |
| authToken | string | null | The authentication token. |
| inspectionId | string | null | The current inspection ID. |
| vehicleType | VehicleType | null | The vehicle of the user. |
@@ -33,9 +33,9 @@ To use it, simply wrap you application inside the provider component and pass it
```tsx
import { MonkAppStateProvider } from '@monkvision/common';
-import { CaptureAppConfig } from '@monkvision/types';
+import { PhotoCaptureAppConfig } from '@monkvision/types';
-const AppConfig: CaptureAppConfig = {
+const AppConfig: PhotoCaptureAppConfig = {
...
};
diff --git a/documentation/docs/configuration.md b/documentation/docs/configuration.md
index ffa8e36b1..ac043d3da 100644
--- a/documentation/docs/configuration.md
+++ b/documentation/docs/configuration.md
@@ -4,57 +4,110 @@ sidebar_position: 3
# Configuration
Most of the web applications integrating the MonkJs SDK will need the same configuration properties. To simplify the
-syntax when configuring your app, we provide a TypeScript interface called `CaptureAppConfig` that contains the usual
-configuration properties needed. You can create a file in your app that will contain the MonkJs configuration, so that
-it will be easy to modify the config properties if needed:
+syntax when configuring your app, we provide a TypeScript interfaces called (`PhotoCaptureAppConfig` and
+`VideoCaptureAppConfig`) that contains the usual configuration properties needed for both the PhotoCapture and
+VideoCapture workflows. You can create a file in your app that will contain the MonkJs configuration, so that it will be
+easy to modify the config properties if needed:
```typescript
-import { CaptureAppConfig } from '@monkvision/types';
+// config.ts
+import { PhotoCaptureAppConfig } from '@monkvision/types';
-export const MonkJsConfig: CaptureAppConfig = {
+export const MonkJsConfig: PhotoCaptureAppConfig = {
...
};
```
This configuration object can then be passed to components like `` or ``.
+## Live Configs
+MonkJs now also offers a way to configure Live Configurations for your web applications. This allows MonkJs apps to
+fetch their configurations (`PhotoCaptureAppConfig` or `VideoCaptureAppConfig`) from a GCP Bucket on startup. By doing
+this, the configurations of the applications can be modified without having to re-deploy the apps. Each live
+configuration consists of a JSON file stored in a public bucket on Monk's Google Cloud instances, identified by a unique
+ID. It is not possible for now to set up a custom hosting service for the live configs, but this feature should arrive
+soon. In order to set up a live configuration on one of your apps, you can simply use the following provider in your
+app :
+```tsx
+import { LiveConfigAppProvider } from '@monkvision/common-ui-web';
+
+function App() {
+ return (
+
+ ...
+
+ );
+}
+```
+
+This component will automatically fetch the given live configuration from one of our buckets, and set up a
+`MonkAppStateProvider` in your app by passing it the fetched configuration.
+
## Available Configuration Options
-The following table lists the available configuration options in the `CaptureAppConfig` interface :
-
-| Name | Type | Description | Required | Default Value |
-|------------------------------------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------|-----------------------------|
-| allowManualLogin | `boolean` | Indicates if manual login and logout should be enabled or not. | ✔️ | |
-| fetchFromSearchParams | `boolean` | Indicates if the app state (auth token, inspection ID etc.) should be fetched from the URL search params. | ✔️ | |
-| allowVehicleTypeSelection | `boolean` | Indicates if manual vehicle type selection should be enabled if the vehicle type is not defined. | ✔️ | |
-| enableSteeringWheelPosition | `boolean` | Indicates if the capture Sights should vary based on the steering wheel position (right or left). | ✔️ | |
-| defaultVehicleType | `VehicleType` | Default vehicle type to use if no vehicle type has been specified. | ✔️ | |
-| defaultSteeringWheelPosition | `SteeringWheelPosition` | Default steering wheel position to use if no steering wheel position has been specified. | if `enableSteeringWheelPosition` is set to `true` | |
-| sights | `Record<..., string[]>` | A map associating each vehicle type supported by the app to a list of sight IDs. If `enableSteeringWheelPosition` is set to `true`, it's a map associating each steering wheel position to this map. | ✔️ | |
-| allowCreateInspection | `boolean` | Indicates if automatic inspection creation should be enabled in the app. | ✔️ | |
-| createInspectionOptions | `CreateInspectionOptions` | Options used when automatically creating an inspection. | if `allowCreateInspection` is set to `true` | |
-| apiDomain | `string` | The API domain used to communicate with the API. | ✔️ | |
-| requiredApiPermissions | `MonkApiPermission[]` | Required API permission that the user must have to use the current app. | | |
-| palette | `Partial` | Custom color palette to use in the app. | | |
-| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | |
-| maxUploadDurationWarning | `number` | Max upload duration in milliseconds before showing a bad connection warning to the user. Use `-1` to never display the warning. | | `15000` |
-| useAdaptiveImageQuality | `boolean` | Boolean indicating if the image quality should be downgraded automatically in case of low connection. | | `true` |
-| showCloseButton | `boolean` | Indicates if the close button should be displayed in the HUD on top of the Camera preview. | | `false` |
-| startTasksOnComplete | `boolean | TaskName[]` | Value indicating if tasks should be started at the end of the inspection. See the `inspection-capture-web` package doc for more info. | | `true` |
-| additionalTasks | `TaskName[]` | An optional list of additional tasks to run on every Sight of the inspection. | | |
-| tasksBySight | `Record` | Record associating each sight with a list of tasks to execute for it. If not provided, the default tasks of the sight will be used. | | |
-| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` |
-| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` |
-| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` |
-| quality | `number` | Value indicating image quality for the compression output. | | `0.6` |
-| allowSkipRetake | `boolean` | If compliance is enabled, this prop indicate if the user is allowed to skip the retaking process if some pictures are not compliant. | | `false` |
-| enableCompliance | `boolean` | Indicates if compliance checks should be enabled or not. | | `true` |
-| enableCompliancePerSight | `string[]` | Array of Sight IDs that indicates for which sight IDs the compliance should be enabled. | | |
-| complianceIssues | `ComplianceIssue[]` | If compliance checks are enabled, this property can be used to select a list of compliance issues to check. | | `DEFAULT_COMPLIANCE_ISSUES` |
-| complianceIssuesPerSight | `Record` | A map associating Sight IDs to a list of compliance issues to check. | | |
-| useLiveCompliance | `boolean` | Indicates if live compliance should be enabled or not. | | `false` |
-| customComplianceThresholds | `CustomComplianceThresholds` | Custom thresholds that can be used to modify the strictness of the compliance for certain compliance issues. | | |
-| customComplianceThresholdsPerSight | `Record` | A map associating Sight IDs to custom compliance thresholds. | | |
-## Live Configs
-MonkJs will soon offer a way to set up live configurations in your web applications that will allow you to configure the
-SDK on the go without having to re-deploy your app. This feature is still in development.
+### Shared Configuration Options
+The following table lists the options available in both the PhotoCapture and VideoCapture configurations :
+
+| Name | Type | Description | Required | Default Value |
+|--------------------------|------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------|---------------------------|
+| workflow | `CaptureWorkflow` | Specifies which capture workflow this app is meant to be used for. The config options available change based on this param. | ✔️ | |
+| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` |
+| quality | `number` | Value indicating image quality for the compression output. | | `0.6` |
+| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` |
+| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` |
+| additionalTasks | `TaskName[]` | An optional list of additional tasks to run on every image of the inspection. | | |
+| startTasksOnComplete | `boolean | TaskName[]` | Value indicating if tasks should be started at the end of the inspection. See the `inspection-capture-web` package doc for more info. | | `true` |
+| allowManualLogin | `boolean` | Indicates if manual login and logout should be enabled or not. | ✔️ | |
+| fetchFromSearchParams | `boolean` | Indicates if the app state (auth token, inspection ID etc.) should be fetched from the URL search params. | ✔️ | |
+| apiDomain | `string` | The API domain used to communicate with the API. | ✔️ | |
+| thumbnailDomain | `string` | The API domain used to communicate with the resize microservice. | ✔️ | |
+| requiredApiPermissions | `MonkApiPermission[]` | Required API permission that the user must have to use the current app. | | |
+| palette | `Partial` | Custom color palette to use in the app. | | |
+| allowCreateInspection | `boolean` | Indicates if automatic inspection creation should be enabled in the app. | ✔️ | |
+| createInspectionOptions | `CreateInspectionOptions` | Options used when automatically creating an inspection. | if `allowCreateInspection` is set to `true` | |
+
+## PhotoCapture Configuration Options
+The following table lists the available configuration options in the `PhotoCaptureAppConfig` interface.
+
+*Note : PhotoCapture configurations must have their `workflow` property set to `CaptureWorkflow.PHOTO`.*
+
+| Name | Type | Description | Required | Default Value |
+|------------------------------------|----------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------|----------------------------------------------|
+| enableCompliance | `boolean` | Indicates if compliance checks should be enabled or not. | | `true` |
+| enableCompliancePerSight | `string[]` | Array of Sight IDs that indicates for which sight IDs the compliance should be enabled. | | |
+| complianceIssues | `ComplianceIssue[]` | If compliance checks are enabled, this property can be used to select a list of compliance issues to check. | | `DEFAULT_COMPLIANCE_ISSUES` |
+| complianceIssuesPerSight | `Record` | A map associating Sight IDs to a list of compliance issues to check. | | |
+| useLiveCompliance | `boolean` | Indicates if live compliance should be enabled or not. | | `false` |
+| customComplianceThresholds | `CustomComplianceThresholds` | Custom thresholds that can be used to modify the strictness of the compliance for certain compliance issues. | | |
+| customComplianceThresholdsPerSight | `Record` | A map associating Sight IDs to custom compliance thresholds. | | |
+| tasksBySight | `Record` | Record associating each sight with a list of tasks to execute for it. If not provided, the default tasks of the sight will be used. | | |
+| showCloseButton | `boolean` | Indicates if the close button should be displayed in the HUD on top of the Camera preview. | | `false` |
+| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | |
+| useAdaptiveImageQuality | `boolean` | Boolean indicating if the image quality should be downgraded automatically in case of low connection. | | `true` |
+| maxUploadDurationWarning | `number` | Max upload duration in milliseconds before showing a bad connection warning to the user. Use `-1` to never display the warning. | | `15000` |
+| allowSkipRetake | `boolean` | If compliance is enabled, this prop indicate if the user is allowed to skip the retaking process if some pictures are not compliant. | | `false` |
+| enableAddDamage | `boolean` | Boolean indicating if `Add Damage` feature should be enabled or not. | | `true` |
+| sightGuidelines | `sightGuideline[]` | A collection of sight guidelines in different language with a list of sightIds associate to it. | | |
+| enableSightGuideline | `boolean` | Boolean indicating whether the sight guideline feature is enabled. If disabled, the guideline text will be hidden. | | `true` |
+| defaultVehicleType | `VehicleType` | Default vehicle type to use if no vehicle type has been specified. | ✔️ | |
+| allowVehicleTypeSelection | `boolean` | Indicates if manual vehicle type selection should be enabled if the vehicle type is not defined. | ✔️ | |
+| enableTutorial | `PhotoCaptureTutorialOption` | Options for displaying the photo capture tutorial. | | `PhotoCaptureTutorialOption.FIRST_TIME_ONLY` |
+| allowSkipTutorial | `boolean` | Boolean indicating if the user can skip the PhotoCapture tutorial. | | `true` |
+| enableSightTutorial | `boolean` | Boolean indicating whether the sight tutorial feature is enabled. | | `true` |
+| enableSteeringWheelPosition | `boolean` | Indicates if the capture Sights should vary based on the steering wheel position (right or left). | ✔️ | |
+| sights | `Record<..., string[]>` | A map associating each vehicle type supported by the app to a list of sight IDs. If `enableSteeringWheelPosition` is set to `true`, it's a map associating each steering wheel position to this map. | ✔️ | |
+| defaultSteeringWheelPosition | `SteeringWheelPosition` | Default steering wheel position to use if no steering wheel position has been specified. | if `enableSteeringWheelPosition` is set to `true` | |
+
+## VideoCapture Configuration Options
+The following table lists the available configuration options in the `VideoCaptureAppConfig` interface.
+
+*Note : PhotoCapture configurations must have their `workflow` property set to `CaptureWorkflow.VIDEO`.*
+
+| Name | Type | Description | Required | Default Value |
+|-----------------------------|-----------|----------------------------------------------------------------------------------------------------------------|----------|---------------|
+| minRecordingDuration | `number` | The minimum duration of a recording in milliseconds. | | `15000` |
+| maxRetryCount | `number` | The maximum number of retries for failed image uploads. | | `3` |
+| enableFastWalkingWarning | `boolean` | Boolean indicating if a warning should be shown to the user when they are walking too fast around the vehicle. | | `true` |
+| enablePhoneShakingWarning | `boolean` | Boolean indicating if a warning should be shown to the user when they are shaking their phone too much. | | `true` |
+| fastWalkingWarningCooldown | `number` | The duration (in milliseconds) to wait between fast walking warnings. | | `4000` |
+| phoneShakingWarningCooldown | `number` | The duration (in milliseconds) to wait between phone shaking warnings. | | `4000` |
diff --git a/documentation/docs/monitoring.md b/documentation/docs/monitoring.md
index 009861389..ef0c6285e 100644
--- a/documentation/docs/monitoring.md
+++ b/documentation/docs/monitoring.md
@@ -1,5 +1,5 @@
---
-sidebar_position: 7
+sidebar_position: 8
---
# Monitoring
diff --git a/documentation/docs/packages/_category_.json b/documentation/docs/packages/_category_.json
index 945066ce1..c0668da8e 100644
--- a/documentation/docs/packages/_category_.json
+++ b/documentation/docs/packages/_category_.json
@@ -1,6 +1,6 @@
{
"label": "Packages",
- "position": 11,
+ "position": 12,
"link": {
"type": "generated-index",
"description": "This page lists the NPM packages available in the MonkJs SDK."
diff --git a/documentation/docs/v3-docs/_category_.json b/documentation/docs/v3-docs/_category_.json
index 0c726cbd6..c7731fceb 100644
--- a/documentation/docs/v3-docs/_category_.json
+++ b/documentation/docs/v3-docs/_category_.json
@@ -1,6 +1,6 @@
{
"label": "V3 Docs",
- "position": 12,
+ "position": 13,
"link": {
"type": "doc",
"id": "v3-docs/index"
diff --git a/documentation/docs/video-capture-workflow.md b/documentation/docs/video-capture-workflow.md
new file mode 100644
index 000000000..612289edc
--- /dev/null
+++ b/documentation/docs/video-capture-workflow.md
@@ -0,0 +1,38 @@
+---
+sidebar_position: 7
+---
+
+# Video Capture Workflow
+Along with the Photo Capture workflow, the Video Capture workflow is one of the two ways you can add pictures manually
+to a Monk inspection that you created. The workflow is as follows :
+- Display a camera preview to the user, allowing them to record a video of their vehicle while walking around it
+- Also allow the user to manually take pictures while doing it
+- Once the vehicle walkaround is completed (around 1min), finish the process by asking the API to start the inspection
+ tasks
+
+## VideoCapture Component
+The `@monkvision/inspection-capture-web` exports a component called `VideoCapture`. This component is a ready-to-use
+single page component, that implements the whole Video Capture workflow. In order to use it :
+- Create a new inspection (make sure to add the tasks you need and to set the `isVideoCapture` param to `true`)
+- Pass the inspection ID, along with the auth token and api configuration to the `VideoCapture` component
+- Once the user has completed the inspection, the `onComplete` callback will be called
+
+```tsx
+import { sights } from '@monkvision/sights';
+import { VideoCapture } from '@monkvision/inspection-capture-web';
+
+const apiDomain = 'api.preview.monk.ai/v1';
+
+export function MonkVideoCapturePage({ authToken }) {
+ return (
+ { /* Navigate to another page */ }}
+ />
+ );
+}
+```
+
+The complete list of configuration options for this component is available in the `@monkvision/inspection-capture-web`
+[README file](https://github.com/monkvision/monkjs/blob/main/packages/inspection-capture-web/README.md).
diff --git a/documentation/package.json b/documentation/package.json
index 4095739e3..c1694029d 100644
--- a/documentation/package.json
+++ b/documentation/package.json
@@ -17,6 +17,7 @@
"lint:fix": "yarn run typecheck && yarn run prettier:fix && yarn run eslint:fix && yarn run svgo"
},
"dependencies": {
+ "@auth0/auth0-react": "^2.2.4",
"@docusaurus/core": "2.4.3",
"@docusaurus/plugin-content-pages": "^2.4.3",
"@docusaurus/preset-classic": "2.4.3",
diff --git a/documentation/src/utils/schemas.ts b/documentation/src/utils/schemas.ts
deleted file mode 100644
index e7e879fe7..000000000
--- a/documentation/src/utils/schemas.ts
+++ /dev/null
@@ -1,327 +0,0 @@
-import { z, CustomErrorParams } from 'zod';
-import {
- CameraResolution,
- ComplianceIssue,
- CompressionFormat,
- CurrencyCode,
- DeviceOrientation,
- MileageUnit,
- MonkApiPermission,
- PhotoCaptureTutorialOption,
- SteeringWheelPosition,
- TaskName,
- VehicleType,
-} from '@monkvision/types';
-import { sights } from '@monkvision/sights';
-import { flatten } from '@monkvision/common';
-
-function isValidSightId(sightId: string): boolean {
- return !!sights[sightId];
-}
-
-function validateSightIds(value?: string[] | Record): boolean {
- if (!value) {
- return true;
- }
- const sightIds = Array.isArray(value) ? value : Object.keys(value);
- return sightIds.every(isValidSightId);
-}
-
-function getInvalidSightIdsMessage(value?: string[] | Record): CustomErrorParams {
- if (!value) {
- return {};
- }
- const sightIds = Array.isArray(value) ? value : Object.keys(value);
- const invalidIds = sightIds.filter((sightId) => !isValidSightId(sightId)).join(', ');
- const plural = invalidIds.length > 1 ? 's' : '';
- return { message: `Invalid sight ID${plural} : ${invalidIds}` };
-}
-
-function getAllSightsByVehicleType(
- vehicleSights?: Partial>,
-): string[] | undefined {
- return vehicleSights ? flatten(Object.values(vehicleSights)) : undefined;
-}
-
-export const CompressionOptionsSchema = z.object({
- format: z.nativeEnum(CompressionFormat),
- quality: z.number().gte(0).lte(1),
-});
-
-export const CameraConfigSchema = z
- .object({
- resolution: z.nativeEnum(CameraResolution).optional(),
- allowImageUpscaling: z.boolean().optional(),
- })
- .and(CompressionOptionsSchema.partial());
-
-export const CustomComplianceThresholdsSchema = z
- .object({
- blurriness: z.number().gte(0).lte(1).optional(),
- overexposure: z.number().gte(0).lte(1).optional(),
- underexposure: z.number().gte(0).lte(1).optional(),
- lensFlare: z.number().gte(0).lte(1).optional(),
- wetness: z.number().gte(0).lte(1).optional(),
- snowness: z.number().gte(0).lte(1).optional(),
- dirtiness: z.number().gte(0).lte(1).optional(),
- reflections: z.number().gte(0).lte(1).optional(),
- zoom: z
- .object({
- min: z.number().gte(0).lte(1),
- max: z.number().gte(0).lte(1),
- })
- .optional(),
- })
- .refine((thresholds) => !thresholds.zoom || thresholds.zoom.min < thresholds.zoom.max, {
- message: 'Min zoom threshold must be smaller than max zoom threshold',
- });
-
-export const ComplianceOptionsSchema = z.object({
- enableCompliance: z.boolean().optional(),
- enableCompliancePerSight: z
- .array(z.string())
- .optional()
- .refine(validateSightIds, getInvalidSightIdsMessage),
- complianceIssues: z.array(z.nativeEnum(ComplianceIssue)).optional(),
- complianceIssuesPerSight: z
- .record(z.string(), z.array(z.nativeEnum(ComplianceIssue)))
- .optional()
- .refine(validateSightIds, getInvalidSightIdsMessage),
- useLiveCompliance: z.boolean().optional(),
- customComplianceThresholds: CustomComplianceThresholdsSchema.optional(),
- customComplianceThresholdsPerSight: z
- .record(z.string(), CustomComplianceThresholdsSchema)
- .optional()
- .refine(validateSightIds, getInvalidSightIdsMessage),
-});
-
-export const SightGuidelineSchema = z.object({
- sightIds: z.array(z.string()),
- en: z.string(),
- fr: z.string(),
- de: z.string(),
- nl: z.string(),
-});
-
-export const AccentColorVariantsSchema = z.object({
- xdark: z.string(),
- dark: z.string(),
- base: z.string(),
- light: z.string(),
- xlight: z.string(),
-});
-
-export const TextColorVariantsSchema = z.object({
- primary: z.string(),
- secondary: z.string(),
- disabled: z.string(),
- white: z.string(),
- black: z.string(),
- link: z.string(),
- linkInverted: z.string(),
-});
-
-export const BackgroundColorVariantsSchema = z.object({
- dark: z.string(),
- base: z.string(),
- light: z.string(),
-});
-
-export const SurfaceColorVariantsSchema = z.object({
- dark: z.string(),
- light: z.string(),
-});
-
-export const OutlineColorVariantsSchema = z.object({
- base: z.string(),
-});
-
-export const MonkPaletteSchema = z.object({
- primary: AccentColorVariantsSchema,
- secondary: AccentColorVariantsSchema,
- alert: AccentColorVariantsSchema,
- caution: AccentColorVariantsSchema,
- success: AccentColorVariantsSchema,
- information: AccentColorVariantsSchema,
- text: TextColorVariantsSchema,
- background: BackgroundColorVariantsSchema,
- surface: SurfaceColorVariantsSchema,
- outline: OutlineColorVariantsSchema,
-});
-
-export const SightsByVehicleTypeSchema = z
- .record(z.nativeEnum(VehicleType), z.array(z.string()))
- .refine(
- (vehicleSights) => validateSightIds(getAllSightsByVehicleType(vehicleSights)),
- (vehicleSights) => getInvalidSightIdsMessage(getAllSightsByVehicleType(vehicleSights)),
- );
-
-export const SteeringWheelDiscriminatedUnionSchema = z.discriminatedUnion(
- 'enableSteeringWheelPosition',
- [
- z.object({
- enableSteeringWheelPosition: z.literal(false),
- sights: SightsByVehicleTypeSchema,
- }),
- z.object({
- enableSteeringWheelPosition: z.literal(true),
- defaultSteeringWheelPosition: z.nativeEnum(SteeringWheelPosition),
- sights: z.record(z.nativeEnum(SteeringWheelPosition), SightsByVehicleTypeSchema),
- }),
- ],
-);
-
-export const TaskCallbackOptionsSchema = z.object({
- url: z.string(),
- headers: z.record(z.string(), z.string()),
- params: z.record(z.string(), z.unknown()).optional(),
- event: z.string().optional(),
-});
-
-export const CreateDamageDetectionTaskOptionsSchema = z.object({
- name: z.literal(TaskName.DAMAGE_DETECTION),
- damageScoreThreshold: z.number().gte(0).lte(1).optional(),
- generateDamageVisualOutput: z.boolean().optional(),
- generateSubimageDamages: z.boolean().optional(),
- generateSubimageParts: z.boolean().optional(),
-});
-
-export const CreateHinlTaskOptionsSchema = z.object({
- name: z.literal(TaskName.HUMAN_IN_THE_LOOP),
- callbacks: z.array(TaskCallbackOptionsSchema).optional(),
-});
-
-export const CreatePricingTaskOptionsSchema = z.object({
- name: z.literal(TaskName.PRICING),
- outputFormat: z.string().optional(),
- config: z.string().optional(),
- methodology: z.string().optional(),
-});
-
-export const InspectionCreateTaskSchema = z
- .nativeEnum(TaskName)
- .or(CreateDamageDetectionTaskOptionsSchema)
- .or(CreateHinlTaskOptionsSchema)
- .or(CreatePricingTaskOptionsSchema);
-
-export const AdditionalDataSchema = z.record(z.string(), z.unknown());
-
-export const InspectionCreateVehicleSchema = z.object({
- brand: z.string().optional(),
- model: z.string().optional(),
- plate: z.string().optional(),
- type: z.string().optional(),
- mileageUnit: z.nativeEnum(MileageUnit).optional(),
- mileageValue: z.number().optional(),
- marketValueUnit: z.nativeEnum(CurrencyCode).optional(),
- marketValue: z.number().optional(),
- vin: z.string().optional(),
- color: z.string().optional(),
- exteriorCleanliness: z.string().optional(),
- interiorCleanliness: z.string().optional(),
- dateOfCirculation: z.string().optional(),
- duplicateKeys: z.boolean().optional(),
- expertiseRequested: z.boolean().optional(),
- carRegistration: z.boolean().optional(),
- vehicleQuotation: z.number().optional(),
- tradeInOffer: z.number().optional(),
- ownerInfo: z.record(z.string().optional(), z.unknown()).optional(),
- additionalData: AdditionalDataSchema.optional(),
-});
-
-export const CreateInspectionOptionsSchema = z.object({
- tasks: z.array(InspectionCreateTaskSchema),
- vehicle: InspectionCreateVehicleSchema.optional(),
- useDynamicCrops: z.boolean().optional(),
- enablePricingV1: z.boolean().optional(),
- additionalData: AdditionalDataSchema.optional(),
-});
-
-export const CreateInspectionDiscriminatedUnionSchema = z.discriminatedUnion(
- 'allowCreateInspection',
- [
- z.object({
- allowCreateInspection: z.literal(false),
- }),
- z.object({
- allowCreateInspection: z.literal(true),
- createInspectionOptions: CreateInspectionOptionsSchema,
- }),
- ],
-);
-
-const domainsByEnv = {
- staging: {
- api: 'api.staging.monk.ai/v1',
- thumbnail: 'europe-west1-monk-staging-321715.cloudfunctions.net/image_resize',
- },
- preview: {
- api: 'api.preview.monk.ai/v1',
- thumbnail: 'europe-west1-monk-preview-321715.cloudfunctions.net/image_resize',
- },
- production: {
- api: 'api.monk.ai/v1',
- thumbnail: 'europe-west1-monk-prod.cloudfunctions.net/image_resize',
- },
-};
-
-const apiDomains = Object.values(domainsByEnv).map((env) => env.api) as [string, ...string[]];
-const thumbnailDomains = Object.values(domainsByEnv).map((env) => env.thumbnail) as [
- string,
- ...string[],
-];
-
-export const DomainsSchema = z
- .object({
- apiDomain: z.enum(apiDomains),
- thumbnailDomain: z.enum(thumbnailDomains),
- })
- .refine(
- (data) => {
- const apiEnv = Object.values(domainsByEnv).find((env) => env.api === data.apiDomain);
- const thumbnailEnv = Object.values(domainsByEnv).find(
- (env) => env.thumbnail === data.thumbnailDomain,
- );
- return !!apiEnv && apiEnv === thumbnailEnv;
- },
- (data) => ({
- message: `The selected thumbnailDomain must correspond to the selected apiDomain. Please use the corresponding thumbnailDomain: ${
- thumbnailDomains[apiDomains.indexOf(data.apiDomain)]
- }`,
- path: ['thumbnailDomain'],
- }),
- );
-
-export const LiveConfigSchema = z
- .object({
- id: z.string(),
- description: z.string(),
- additionalTasks: z.array(z.nativeEnum(TaskName)).optional(),
- tasksBySight: z.record(z.string(), z.array(z.nativeEnum(TaskName))).optional(),
- startTasksOnComplete: z
- .boolean()
- .or(z.array(z.nativeEnum(TaskName)))
- .optional(),
- showCloseButton: z.boolean().optional(),
- enforceOrientation: z.nativeEnum(DeviceOrientation).optional(),
- maxUploadDurationWarning: z.number().positive().or(z.literal(-1)).optional(),
- useAdaptiveImageQuality: z.boolean().optional(),
- allowSkipRetake: z.boolean().optional(),
- enableAddDamage: z.boolean().optional(),
- enableSightGuidelines: z.boolean().optional(),
- sightGuidelines: z.array(SightGuidelineSchema).optional(),
- enableTutorial: z.nativeEnum(PhotoCaptureTutorialOption).optional(),
- allowSkipTutorial: z.boolean().optional(),
- enableSightTutorial: z.boolean().optional(),
- defaultVehicleType: z.nativeEnum(VehicleType),
- allowManualLogin: z.boolean(),
- allowVehicleTypeSelection: z.boolean(),
- fetchFromSearchParams: z.boolean(),
- requiredApiPermissions: z.array(z.nativeEnum(MonkApiPermission)).optional(),
- palette: MonkPaletteSchema.partial().optional(),
- })
- .and(DomainsSchema)
- .and(SteeringWheelDiscriminatedUnionSchema)
- .and(CreateInspectionDiscriminatedUnionSchema)
- .and(CameraConfigSchema)
- .and(ComplianceOptionsSchema);
diff --git a/documentation/src/utils/schemas/cameraConfig.schema.ts b/documentation/src/utils/schemas/cameraConfig.schema.ts
new file mode 100644
index 000000000..f16bc4089
--- /dev/null
+++ b/documentation/src/utils/schemas/cameraConfig.schema.ts
@@ -0,0 +1,14 @@
+import { z } from 'zod';
+import { CameraResolution, CompressionFormat } from '@monkvision/types';
+
+export const CompressionOptionsSchema = z.object({
+ format: z.nativeEnum(CompressionFormat),
+ quality: z.number().gte(0).lte(1),
+});
+
+export const CameraConfigSchema = z
+ .object({
+ resolution: z.nativeEnum(CameraResolution).optional(),
+ allowImageUpscaling: z.boolean().optional(),
+ })
+ .and(CompressionOptionsSchema.partial());
diff --git a/documentation/src/utils/schemas/compliance.schema.ts b/documentation/src/utils/schemas/compliance.schema.ts
new file mode 100644
index 000000000..7d69efd63
--- /dev/null
+++ b/documentation/src/utils/schemas/compliance.schema.ts
@@ -0,0 +1,46 @@
+import { z } from 'zod';
+import { ComplianceIssue } from '@monkvision/types';
+import {
+ getInvalidSightIdsMessage,
+ validateSightIds,
+} from '@site/src/utils/schemas/sights.validator';
+
+export const CustomComplianceThresholdsSchema = z
+ .object({
+ blurriness: z.number().gte(0).lte(1).optional(),
+ overexposure: z.number().gte(0).lte(1).optional(),
+ underexposure: z.number().gte(0).lte(1).optional(),
+ lensFlare: z.number().gte(0).lte(1).optional(),
+ wetness: z.number().gte(0).lte(1).optional(),
+ snowness: z.number().gte(0).lte(1).optional(),
+ dirtiness: z.number().gte(0).lte(1).optional(),
+ reflections: z.number().gte(0).lte(1).optional(),
+ zoom: z
+ .object({
+ min: z.number().gte(0).lte(1),
+ max: z.number().gte(0).lte(1),
+ })
+ .optional(),
+ })
+ .refine((thresholds) => !thresholds.zoom || thresholds.zoom.min < thresholds.zoom.max, {
+ message: 'Min zoom threshold must be smaller than max zoom threshold',
+ });
+
+export const ComplianceOptionsSchema = z.object({
+ enableCompliance: z.boolean().optional(),
+ enableCompliancePerSight: z
+ .array(z.string())
+ .optional()
+ .refine(validateSightIds, getInvalidSightIdsMessage),
+ complianceIssues: z.array(z.nativeEnum(ComplianceIssue)).optional(),
+ complianceIssuesPerSight: z
+ .record(z.string(), z.array(z.nativeEnum(ComplianceIssue)))
+ .optional()
+ .refine(validateSightIds, getInvalidSightIdsMessage),
+ useLiveCompliance: z.boolean().optional(),
+ customComplianceThresholds: CustomComplianceThresholdsSchema.optional(),
+ customComplianceThresholdsPerSight: z
+ .record(z.string(), CustomComplianceThresholdsSchema)
+ .optional()
+ .refine(validateSightIds, getInvalidSightIdsMessage),
+});
diff --git a/documentation/src/utils/schemas/createInspection.schema.ts b/documentation/src/utils/schemas/createInspection.schema.ts
new file mode 100644
index 000000000..59ecf6a78
--- /dev/null
+++ b/documentation/src/utils/schemas/createInspection.schema.ts
@@ -0,0 +1,82 @@
+import { z } from 'zod';
+import { CurrencyCode, MileageUnit, TaskName } from '@monkvision/types';
+
+export const TaskCallbackOptionsSchema = z.object({
+ url: z.string(),
+ headers: z.record(z.string(), z.string()),
+ params: z.record(z.string(), z.unknown()).optional(),
+ event: z.string().optional(),
+});
+
+export const CreateDamageDetectionTaskOptionsSchema = z.object({
+ name: z.literal(TaskName.DAMAGE_DETECTION),
+ damageScoreThreshold: z.number().gte(0).lte(1).optional(),
+ generateDamageVisualOutput: z.boolean().optional(),
+ generateSubimageDamages: z.boolean().optional(),
+ generateSubimageParts: z.boolean().optional(),
+});
+
+export const CreateHinlTaskOptionsSchema = z.object({
+ name: z.literal(TaskName.HUMAN_IN_THE_LOOP),
+ callbacks: z.array(TaskCallbackOptionsSchema).optional(),
+});
+
+export const CreatePricingTaskOptionsSchema = z.object({
+ name: z.literal(TaskName.PRICING),
+ outputFormat: z.string().optional(),
+ config: z.string().optional(),
+ methodology: z.string().optional(),
+});
+
+export const InspectionCreateTaskSchema = z
+ .nativeEnum(TaskName)
+ .or(CreateDamageDetectionTaskOptionsSchema)
+ .or(CreateHinlTaskOptionsSchema)
+ .or(CreatePricingTaskOptionsSchema);
+
+export const AdditionalDataSchema = z.record(z.string(), z.unknown());
+
+export const InspectionCreateVehicleSchema = z.object({
+ brand: z.string().optional(),
+ model: z.string().optional(),
+ plate: z.string().optional(),
+ type: z.string().optional(),
+ mileageUnit: z.nativeEnum(MileageUnit).optional(),
+ mileageValue: z.number().optional(),
+ marketValueUnit: z.nativeEnum(CurrencyCode).optional(),
+ marketValue: z.number().optional(),
+ vin: z.string().optional(),
+ color: z.string().optional(),
+ exteriorCleanliness: z.string().optional(),
+ interiorCleanliness: z.string().optional(),
+ dateOfCirculation: z.string().optional(),
+ duplicateKeys: z.boolean().optional(),
+ expertiseRequested: z.boolean().optional(),
+ carRegistration: z.boolean().optional(),
+ vehicleQuotation: z.number().optional(),
+ tradeInOffer: z.number().optional(),
+ ownerInfo: z.record(z.string().optional(), z.unknown()).optional(),
+ additionalData: AdditionalDataSchema.optional(),
+});
+
+export const CreateInspectionOptionsSchema = z.object({
+ tasks: z.array(InspectionCreateTaskSchema),
+ vehicle: InspectionCreateVehicleSchema.optional(),
+ useDynamicCrops: z.boolean().optional(),
+ enablePricingV1: z.boolean().optional(),
+ isVideoCapture: z.boolean().optional(),
+ additionalData: AdditionalDataSchema.optional(),
+});
+
+export const CreateInspectionDiscriminatedUnionSchema = z.discriminatedUnion(
+ 'allowCreateInspection',
+ [
+ z.object({
+ allowCreateInspection: z.literal(false),
+ }),
+ z.object({
+ allowCreateInspection: z.literal(true),
+ createInspectionOptions: CreateInspectionOptionsSchema,
+ }),
+ ],
+);
diff --git a/documentation/src/utils/schemas/index.ts b/documentation/src/utils/schemas/index.ts
new file mode 100644
index 000000000..86356eaf4
--- /dev/null
+++ b/documentation/src/utils/schemas/index.ts
@@ -0,0 +1,10 @@
+import { z } from 'zod';
+import { PhotoCaptureAppConfigSchema } from '@site/src/utils/schemas/photoCaptureConfig.schema';
+import { VideoCaptureAppConfigSchema } from '@site/src/utils/schemas/videoCaptureConfig.schema';
+
+export const LiveConfigSchema = z
+ .object({
+ id: z.string(),
+ description: z.string(),
+ })
+ .and(PhotoCaptureAppConfigSchema.or(VideoCaptureAppConfigSchema));
diff --git a/documentation/src/utils/schemas/palette.schema.ts b/documentation/src/utils/schemas/palette.schema.ts
new file mode 100644
index 000000000..2a8270efd
--- /dev/null
+++ b/documentation/src/utils/schemas/palette.schema.ts
@@ -0,0 +1,47 @@
+import { z } from 'zod';
+
+export const AccentColorVariantsSchema = z.object({
+ xdark: z.string(),
+ dark: z.string(),
+ base: z.string(),
+ light: z.string(),
+ xlight: z.string(),
+});
+
+export const TextColorVariantsSchema = z.object({
+ primary: z.string(),
+ secondary: z.string(),
+ disabled: z.string(),
+ white: z.string(),
+ black: z.string(),
+ link: z.string(),
+ linkInverted: z.string(),
+});
+
+export const BackgroundColorVariantsSchema = z.object({
+ dark: z.string(),
+ base: z.string(),
+ light: z.string(),
+});
+
+export const SurfaceColorVariantsSchema = z.object({
+ dark: z.string(),
+ light: z.string(),
+});
+
+export const OutlineColorVariantsSchema = z.object({
+ base: z.string(),
+});
+
+export const MonkPaletteSchema = z.object({
+ primary: AccentColorVariantsSchema,
+ secondary: AccentColorVariantsSchema,
+ alert: AccentColorVariantsSchema,
+ caution: AccentColorVariantsSchema,
+ success: AccentColorVariantsSchema,
+ information: AccentColorVariantsSchema,
+ text: TextColorVariantsSchema,
+ background: BackgroundColorVariantsSchema,
+ surface: SurfaceColorVariantsSchema,
+ outline: OutlineColorVariantsSchema,
+});
diff --git a/documentation/src/utils/schemas/photoCaptureConfig.schema.ts b/documentation/src/utils/schemas/photoCaptureConfig.schema.ts
new file mode 100644
index 000000000..4d0e27766
--- /dev/null
+++ b/documentation/src/utils/schemas/photoCaptureConfig.schema.ts
@@ -0,0 +1,39 @@
+import { z } from 'zod';
+import { SharedCaptureAppConfigSchema } from '@site/src/utils/schemas/sharedConfig.schema';
+import { ComplianceOptionsSchema } from '@site/src/utils/schemas/compliance.schema';
+import {
+ CaptureWorkflow,
+ PhotoCaptureTutorialOption,
+ TaskName,
+ VehicleType,
+} from '@monkvision/types';
+import { SteeringWheelDiscriminatedUnionSchema } from '@site/src/utils/schemas/steeringWheel.schema';
+
+export const SightGuidelineSchema = z.object({
+ sightIds: z.array(z.string()),
+ en: z.string(),
+ fr: z.string(),
+ de: z.string(),
+ nl: z.string(),
+});
+
+export const PhotoCaptureAppConfigSchema = z
+ .object({
+ workflow: z.literal(CaptureWorkflow.PHOTO),
+ tasksBySight: z.record(z.string(), z.array(z.nativeEnum(TaskName))).optional(),
+ showCloseButton: z.boolean().optional(),
+ allowSkipRetake: z.boolean().optional(),
+ enableAddDamage: z.boolean().optional(),
+ maxUploadDurationWarning: z.number().optional(),
+ useAdaptiveImageQuality: z.boolean().optional(),
+ sightGuidelines: z.array(SightGuidelineSchema).optional(),
+ enableSightGuidelines: z.boolean().optional(),
+ defaultVehicleType: z.nativeEnum(VehicleType),
+ allowVehicleTypeSelection: z.boolean(),
+ enableTutorial: z.nativeEnum(PhotoCaptureTutorialOption).optional(),
+ allowSkipTutorial: z.boolean().optional(),
+ enableSightTutorial: z.boolean().optional(),
+ })
+ .and(SharedCaptureAppConfigSchema)
+ .and(ComplianceOptionsSchema)
+ .and(SteeringWheelDiscriminatedUnionSchema);
diff --git a/documentation/src/utils/schemas/sharedConfig.schema.ts b/documentation/src/utils/schemas/sharedConfig.schema.ts
new file mode 100644
index 000000000..d744916ee
--- /dev/null
+++ b/documentation/src/utils/schemas/sharedConfig.schema.ts
@@ -0,0 +1,61 @@
+import { z } from 'zod';
+import { DeviceOrientation, MonkApiPermission, TaskName } from '@monkvision/types';
+import { CameraConfigSchema } from '@site/src/utils/schemas/cameraConfig.schema';
+import { MonkPaletteSchema } from '@site/src/utils/schemas/palette.schema';
+import { CreateInspectionDiscriminatedUnionSchema } from '@site/src/utils/schemas/createInspection.schema';
+
+const domainsByEnv = {
+ staging: {
+ api: 'api.staging.monk.ai/v1',
+ thumbnail: 'europe-west1-monk-staging-321715.cloudfunctions.net/image_resize',
+ },
+ preview: {
+ api: 'api.preview.monk.ai/v1',
+ thumbnail: 'europe-west1-monk-preview-321715.cloudfunctions.net/image_resize',
+ },
+ production: {
+ api: 'api.monk.ai/v1',
+ thumbnail: 'europe-west1-monk-prod.cloudfunctions.net/image_resize',
+ },
+};
+
+const apiDomains = Object.values(domainsByEnv).map((env) => env.api) as [string, ...string[]];
+const thumbnailDomains = Object.values(domainsByEnv).map((env) => env.thumbnail) as [
+ string,
+ ...string[],
+];
+
+export const DomainsSchema = z
+ .object({
+ apiDomain: z.enum(apiDomains),
+ thumbnailDomain: z.enum(thumbnailDomains),
+ })
+ .refine(
+ (data) => {
+ const apiEnv = Object.values(domainsByEnv).find((env) => env.api === data.apiDomain);
+ const thumbnailEnv = Object.values(domainsByEnv).find(
+ (env) => env.thumbnail === data.thumbnailDomain,
+ );
+ return !!apiEnv && apiEnv === thumbnailEnv;
+ },
+ (data) => ({
+ message: `The selected thumbnailDomain must correspond to the selected apiDomain. Please use the corresponding thumbnailDomain: ${
+ thumbnailDomains[apiDomains.indexOf(data.apiDomain)]
+ }`,
+ path: ['thumbnailDomain'],
+ }),
+ );
+
+export const SharedCaptureAppConfigSchema = z
+ .object({
+ additionalTasks: z.array(z.nativeEnum(TaskName)).optional(),
+ startTasksOnComplete: z.array(z.nativeEnum(TaskName)).or(z.boolean()).optional(),
+ enforceOrientation: z.nativeEnum(DeviceOrientation).optional(),
+ allowManualLogin: z.boolean().optional(),
+ fetchFromSearchParams: z.boolean().optional(),
+ requiredApiPermissions: z.array(z.nativeEnum(MonkApiPermission)).optional(),
+ palette: MonkPaletteSchema.partial().optional(),
+ })
+ .and(CameraConfigSchema)
+ .and(DomainsSchema)
+ .and(CreateInspectionDiscriminatedUnionSchema);
diff --git a/documentation/src/utils/schemas/sights.validator.ts b/documentation/src/utils/schemas/sights.validator.ts
new file mode 100644
index 000000000..827e77a5c
--- /dev/null
+++ b/documentation/src/utils/schemas/sights.validator.ts
@@ -0,0 +1,34 @@
+import { VehicleType } from '@monkvision/types';
+import { flatten } from '@monkvision/common';
+import { sights } from '@monkvision/sights';
+import { CustomErrorParams } from 'zod';
+
+export function getAllSightsByVehicleType(
+ vehicleSights?: Partial>,
+): string[] | undefined {
+ return vehicleSights ? flatten(Object.values(vehicleSights)) : undefined;
+}
+
+export function isValidSightId(sightId: string): boolean {
+ return !!sights[sightId];
+}
+
+export function validateSightIds(value?: string[] | Record): boolean {
+ if (!value) {
+ return true;
+ }
+ const sightIds = Array.isArray(value) ? value : Object.keys(value);
+ return sightIds.every(isValidSightId);
+}
+
+export function getInvalidSightIdsMessage(
+ value?: string[] | Record,
+): CustomErrorParams {
+ if (!value) {
+ return {};
+ }
+ const sightIds = Array.isArray(value) ? value : Object.keys(value);
+ const invalidIds = sightIds.filter((sightId) => !isValidSightId(sightId)).join(', ');
+ const plural = invalidIds.length > 1 ? 's' : '';
+ return { message: `Invalid sight ID${plural} : ${invalidIds}` };
+}
diff --git a/documentation/src/utils/schemas/steeringWheel.schema.ts b/documentation/src/utils/schemas/steeringWheel.schema.ts
new file mode 100644
index 000000000..2c4f2ec8f
--- /dev/null
+++ b/documentation/src/utils/schemas/steeringWheel.schema.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import { SteeringWheelPosition, VehicleType } from '@monkvision/types';
+import {
+ getAllSightsByVehicleType,
+ getInvalidSightIdsMessage,
+ validateSightIds,
+} from '@site/src/utils/schemas/sights.validator';
+
+export const SightsByVehicleTypeSchema = z
+ .record(z.nativeEnum(VehicleType), z.array(z.string()))
+ .refine(
+ (vehicleSights) => validateSightIds(getAllSightsByVehicleType(vehicleSights)),
+ (vehicleSights) => getInvalidSightIdsMessage(getAllSightsByVehicleType(vehicleSights)),
+ );
+
+export const SteeringWheelDiscriminatedUnionSchema = z.discriminatedUnion(
+ 'enableSteeringWheelPosition',
+ [
+ z.object({
+ enableSteeringWheelPosition: z.literal(false),
+ sights: SightsByVehicleTypeSchema,
+ }),
+ z.object({
+ enableSteeringWheelPosition: z.literal(true),
+ defaultSteeringWheelPosition: z.nativeEnum(SteeringWheelPosition),
+ sights: z.record(z.nativeEnum(SteeringWheelPosition), SightsByVehicleTypeSchema),
+ }),
+ ],
+);
diff --git a/documentation/src/utils/schemas/videoCaptureConfig.schema.ts b/documentation/src/utils/schemas/videoCaptureConfig.schema.ts
new file mode 100644
index 000000000..37271179e
--- /dev/null
+++ b/documentation/src/utils/schemas/videoCaptureConfig.schema.ts
@@ -0,0 +1,15 @@
+import { z } from 'zod';
+import { CaptureWorkflow } from '@monkvision/types';
+import { SharedCaptureAppConfigSchema } from '@site/src/utils/schemas/sharedConfig.schema';
+
+export const VideoCaptureAppConfigSchema = z
+ .object({
+ workflow: z.literal(CaptureWorkflow.VIDEO),
+ minRecordingDuration: z.number().optional(),
+ maxRetryCount: z.number().optional(),
+ enableFastWalkingWarning: z.boolean().optional(),
+ enablePhoneShakingWarning: z.boolean().optional(),
+ fastWalkingWarningCooldown: z.number().gte(1000).optional(),
+ phoneShakingWarningCooldown: z.number().gte(1000).optional(),
+ })
+ .and(SharedCaptureAppConfigSchema);
diff --git a/packages/camera-web/README.md b/packages/camera-web/README.md
index ddc4af1ea..d39404cc0 100644
--- a/packages/camera-web/README.md
+++ b/packages/camera-web/README.md
@@ -172,11 +172,26 @@ Main component exported by this package, displays a Camera preview and the given
Object passed to Camera HUD components that is used to control the camera
### Properties
-| Prop | Type | Description |
-|-------------------|-----------------------------|----------------------------------------------------------------------------------------------------------|
-| takePicture | () => Promise | A function that you can call to ask the camera to take a picture. |
-| error | UserMediaError | null | The error details if there has been an error when fetching the camera stream. |
-| isLoading | boolean | Boolean indicating if the camera preview is loading. |
-| retry | () => void | A function to retry the camera stream fetching in case of error. |
-| dimensions | PixelDimensions | null | The Camera stream dimensions (`null` if there is no stream). |
-| previewDimensions | PixelDimensions | null | The effective video dimensions of the Camera stream on the client screen (`null` if there is no stream). |
+| Prop | Type | Description |
+|-------------------|--------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| takePicture | () => Promise | A function that you can call to ask the camera to take a picture. |
+| getImageData | () => ImageData | Function used to take a raw screenshot of the camera stream for manual image processing. Performance tracking is disabled for this method (only available using the `takePicture` method). |
+| compressImage | (image: ImageData) => Promise | Function used to compress raw image data into a MonkPicture object. Performance tracking is disabled for this method (only available using the `takePicture` method). |
+| error | UserMediaError | null | The error details if there has been an error when fetching the camera stream. |
+| isLoading | boolean | Boolean indicating if the camera preview is loading. |
+| retry | () => void | A function to retry the camera stream fetching in case of error. |
+| dimensions | PixelDimensions | null | The Camera stream dimensions (`null` if there is no stream). |
+| previewDimensions | PixelDimensions | null | The effective video dimensions of the Camera stream on the client screen (`null` if there is no stream). |
+
+
+## Hooks
+### useCameraPermission
+```tsx
+import { useCameraPermission } from '@monkvision/camera-web';
+
+function TestComponent() {
+ const { requestCameraPermission } = useCameraPermission();
+ return ;
+}
+```
+Custom hook that can be used to request the camera permissions on the current device.
diff --git a/packages/camera-web/src/Camera/Camera.tsx b/packages/camera-web/src/Camera/Camera.tsx
index e1163bc37..3cc2d09e9 100644
--- a/packages/camera-web/src/Camera/Camera.tsx
+++ b/packages/camera-web/src/Camera/Camera.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react';
+import React, { useCallback, useMemo } from 'react';
import {
AllOrNone,
CameraConfig,
@@ -115,6 +115,7 @@ export function Camera({
availableCameraDevices,
selectedCameraDeviceId,
});
+
const isLoading = isPreviewLoading || isTakePictureLoading;
const cameraPreview = useMemo(
() => (
@@ -134,10 +135,15 @@ export function Camera({
[],
);
+ const getImageData = useCallback(() => takeScreenshot(), [takeScreenshot]);
+ const compressImage = useCallback((image: ImageData) => compress(image), [compress]);
+
return HUDComponent ? (
Promise;
+ /**
+ * A function that you can call to get the current raw image data displayed on the camera stream. You can use this
+ * function if you need to apply a custom procesing to the image pixels and don't want the automatic compression logic
+ * of the Camera component. You can use the `handle.compressImage` method to compress the raw image data using the
+ * Camera component's compression configuration. If you just want to take a picture normally, use the
+ * `handle.takePicture` method.
+ *
+ * Note: This method does NOT use any monitoring tracking. The only way to enable monitoring is by taking pictures via
+ * the `handle.takePicture` method.
+ */
+ getImageData: () => ImageData;
+ /**
+ * A function that you can call to compress a raw ImageData (taken using the `handle.compressImage` function) into a
+ * MonkPicture object. This function will use the compression options passed as parameters to the Camera component.
+ *
+ * Note: This method does NOT use any monitoring tracking. The only way to enable monitoring is by taking pictures via
+ * the `handle.takePicture` method.
+ */
+ compressImage: (image: ImageData) => Promise;
/**
* The error details if there has been an error when fetching the camera stream.
*/
diff --git a/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts b/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts
index ddaac2ca4..f57102792 100644
--- a/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts
+++ b/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts
@@ -31,13 +31,13 @@ export interface CameraScreenshotConfig {
*
* @return A ImageData object that contains the raw pixel's data.
*/
-export type TakeScreenshotFunction = (monitoring: InternalCameraMonitoringConfig) => ImageData;
+export type TakeScreenshotFunction = (monitoring?: InternalCameraMonitoringConfig) => ImageData;
function startScreenshotMeasurement(
- monitoring: InternalCameraMonitoringConfig,
dimensions: PixelDimensions | null,
+ monitoring?: InternalCameraMonitoringConfig | undefined,
): void {
- monitoring.transaction?.startMeasurement(ScreenshotMeasurement.operation, {
+ monitoring?.transaction?.startMeasurement(ScreenshotMeasurement.operation, {
data: monitoring.data,
tags: {
[ScreenshotMeasurement.outputResolutionTagName]: dimensions
@@ -50,18 +50,18 @@ function startScreenshotMeasurement(
}
function stopScreenshotMeasurement(
- monitoringConfig: InternalCameraMonitoringConfig,
status: TransactionStatus,
+ monitoring: InternalCameraMonitoringConfig | undefined,
): void {
- monitoringConfig.transaction?.stopMeasurement(ScreenshotMeasurement.operation, status);
+ monitoring?.transaction?.stopMeasurement(ScreenshotMeasurement.operation, status);
}
function setScreeshotSizeMeasurement(
- monitoring: InternalCameraMonitoringConfig,
image: ImageData,
+ monitoring?: InternalCameraMonitoringConfig | undefined,
): void {
const imageSizeBytes = image.data.length;
- monitoring.transaction?.setMeasurement(ScreenshotSizeMeasurement.name, imageSizeBytes, 'byte');
+ monitoring?.transaction?.setMeasurement(ScreenshotSizeMeasurement.name, imageSizeBytes, 'byte');
}
/**
@@ -74,23 +74,23 @@ export function useCameraScreenshot({
dimensions,
}: CameraScreenshotConfig): TakeScreenshotFunction {
return useCallback(
- (monitoring: InternalCameraMonitoringConfig) => {
- startScreenshotMeasurement(monitoring, dimensions);
+ (monitoring?: InternalCameraMonitoringConfig) => {
+ startScreenshotMeasurement(dimensions, monitoring);
const { context } = getCanvasHandle(canvasRef, () =>
- stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR),
+ stopScreenshotMeasurement(TransactionStatus.UNKNOWN_ERROR, monitoring),
);
if (!dimensions) {
- stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR);
+ stopScreenshotMeasurement(TransactionStatus.UNKNOWN_ERROR, monitoring);
throw new Error('Unable to take a picture because the video stream has no dimension.');
}
if (!videoRef.current) {
- stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR);
+ stopScreenshotMeasurement(TransactionStatus.UNKNOWN_ERROR, monitoring);
throw new Error('Unable to take a picture because the video element is null.');
}
context.drawImage(videoRef.current, 0, 0, dimensions.width, dimensions.height);
const imageData = context.getImageData(0, 0, dimensions.width, dimensions.height);
- setScreeshotSizeMeasurement(monitoring, imageData);
- stopScreenshotMeasurement(monitoring, TransactionStatus.OK);
+ setScreeshotSizeMeasurement(imageData, monitoring);
+ stopScreenshotMeasurement(TransactionStatus.OK, monitoring);
return imageData;
},
[dimensions],
diff --git a/packages/camera-web/src/Camera/hooks/useCompression.ts b/packages/camera-web/src/Camera/hooks/useCompression.ts
index 3141f4062..5cb708b44 100644
--- a/packages/camera-web/src/Camera/hooks/useCompression.ts
+++ b/packages/camera-web/src/Camera/hooks/useCompression.ts
@@ -28,46 +28,46 @@ export interface UseCompressionParams {
*/
export type CompressFunction = (
image: ImageData,
- monitoring: InternalCameraMonitoringConfig,
+ monitoring?: InternalCameraMonitoringConfig,
) => Promise;
function startCompressionMeasurement(
- monitoring: InternalCameraMonitoringConfig,
options: CompressionOptions,
image: ImageData,
+ monitoring?: InternalCameraMonitoringConfig | undefined,
): void {
- monitoring.transaction?.startMeasurement(CompressionMeasurement.operation, {
- data: monitoring.data,
+ monitoring?.transaction?.startMeasurement(CompressionMeasurement.operation, {
+ data: monitoring?.data,
tags: {
[CompressionMeasurement.formatTagName]: options.format,
[CompressionMeasurement.qualityTagName]: options.quality,
[CompressionMeasurement.dimensionsTagName]: `${image.width}x${image.height}`,
- ...(monitoring.tags ?? {}),
+ ...(monitoring?.tags ?? {}),
},
description: CompressionMeasurement.description,
});
}
function stopCompressionMeasurement(
- monitoring: InternalCameraMonitoringConfig,
status: TransactionStatus,
+ monitoring?: InternalCameraMonitoringConfig | undefined,
): void {
- monitoring.transaction?.stopMeasurement(CompressionMeasurement.operation, status);
+ monitoring?.transaction?.stopMeasurement(CompressionMeasurement.operation, status);
}
function setCustomMeasurements(
- monitoring: InternalCameraMonitoringConfig,
image: ImageData,
picture: MonkPicture,
+ monitoring?: InternalCameraMonitoringConfig | undefined,
): void {
const imageSizeBytes = image.data.length;
const pictureSizeBytes = picture.blob.size;
- monitoring.transaction?.setMeasurement(
+ monitoring?.transaction?.setMeasurement(
CompressionSizeRatioMeasurement.name,
pictureSizeBytes / imageSizeBytes,
'ratio',
);
- monitoring.transaction?.setMeasurement(PictureSizeMeasurement.name, pictureSizeBytes, 'byte');
+ monitoring?.transaction?.setMeasurement(PictureSizeMeasurement.name, pictureSizeBytes, 'byte');
}
function compressUsingBrowser(
@@ -103,15 +103,15 @@ function compressUsingBrowser(
*/
export function useCompression({ canvasRef, options }: UseCompressionParams): CompressFunction {
return useCallback(
- async (image: ImageData, monitoring: InternalCameraMonitoringConfig) => {
- startCompressionMeasurement(monitoring, options, image);
+ async (image: ImageData, monitoring?: InternalCameraMonitoringConfig) => {
+ startCompressionMeasurement(options, image, monitoring);
try {
const picture = await compressUsingBrowser(image, canvasRef, options);
- setCustomMeasurements(monitoring, image, picture);
- stopCompressionMeasurement(monitoring, TransactionStatus.OK);
+ setCustomMeasurements(image, picture, monitoring);
+ stopCompressionMeasurement(TransactionStatus.OK, monitoring);
return picture;
} catch (err) {
- stopCompressionMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR);
+ stopCompressionMeasurement(TransactionStatus.UNKNOWN_ERROR, monitoring);
throw err;
}
},
diff --git a/packages/camera-web/src/Camera/hooks/useUserMedia.ts b/packages/camera-web/src/Camera/hooks/useUserMedia.ts
index 55e847b17..6236f6658 100644
--- a/packages/camera-web/src/Camera/hooks/useUserMedia.ts
+++ b/packages/camera-web/src/Camera/hooks/useUserMedia.ts
@@ -1,8 +1,8 @@
import { useMonitoring } from '@monkvision/monitoring';
import deepEqual from 'fast-deep-equal';
-import { RefObject, useCallback, useEffect, useRef, useState } from 'react';
+import { RefObject, useCallback, useEffect, useState } from 'react';
import { PixelDimensions } from '@monkvision/types';
-import { isMobileDevice, useObjectMemo } from '@monkvision/common';
+import { isMobileDevice, useIsMounted, useObjectMemo } from '@monkvision/common';
import { analyzeCameraDevices } from './utils';
/**
@@ -97,6 +97,10 @@ export interface UserMediaError {
* @see useUserMedia
*/
export interface UserMediaResult {
+ /**
+ * The getUserMedia function that can be used to fetch the stream data manually if no videoRef is passed.
+ */
+ getUserMedia: () => Promise;
/**
* The resulting video stream. The stream can be null when not initialized or in case of an error.
*/
@@ -180,14 +184,16 @@ function getStreamDimensions(stream: MediaStream, checkOrientation: boolean): Pi
/**
* React hook that wraps the `navigator.mediaDevices.getUserMedia` browser function in order to add React logic layers
* and utility tools :
- * - Creates an effect for `getUserMedia` that will be run everytime some state parameters are updated.
+ * - Creates an effect for `getUserMedia` that will be run everytime some state parameters are updated (the effect is
+ * run only if the videoRef is passed, if not, the `getUserMedia` function must be called manually).
* - Will call `track.applyConstraints` when the video contstraints are updated in order to update the video stream.
* - Makes sure that the `getUserMedia` is only called when it needs to be using memoized state.
* - Provides various utilities such as error catching, loading information and a retry on failure feature.
*
* @param constraints The same media constraints you would pass to the `getUserMedia` function. Note that this hook has
* been designed for video only, so audio constraints could provoke unexpected behaviour.
- * @param videoRef The ref to the video element displaying the camera preview stream.
+ * @param videoRef The ref to the video element displaying the camera preview stream. If the ref is not passed, the
+ * effect will not automatically be called.
* @return The result of this hook contains the resulting video stream, an error object if there has been an error, a
* loading indicator and a retry function that tries to get a camera stream again. See the `UserMediaResult` interface
* for more information.
@@ -195,7 +201,7 @@ function getStreamDimensions(stream: MediaStream, checkOrientation: boolean): Pi
*/
export function useUserMedia(
constraints: MediaStreamConstraints,
- videoRef: RefObject,
+ videoRef: RefObject | null,
): UserMediaResult {
const [stream, setStream] = useState(null);
const [dimensions, setDimensions] = useState(null);
@@ -206,19 +212,12 @@ export function useUserMedia(
const [lastConstraintsApplied, setLastConstraintsApplied] =
useState(null);
const { handleError } = useMonitoring();
- const isActive = useRef(true);
+ const isMounted = useIsMounted();
- let cameraPermissionState: PermissionState | null = null;
- useEffect(() => {
- return () => {
- isActive.current = false;
- };
- }, []);
-
- const handleGetUserMediaError = (err: unknown) => {
+ const handleGetUserMediaError = (err: unknown, permissionState: PermissionState | null) => {
let type = UserMediaErrorType.OTHER;
if (err instanceof Error && err.name === 'NotAllowedError') {
- switch (cameraPermissionState) {
+ switch (permissionState) {
case 'denied':
type = UserMediaErrorType.WEBPAGE_NOT_ALLOWED;
break;
@@ -240,11 +239,13 @@ export function useUserMedia(
};
const onStreamInactive = () => {
- setError({
- type: UserMediaErrorType.STREAM_INACTIVE,
- nativeError: new Error('The camera stream was closed.'),
- });
- setIsLoading(false);
+ if (isMounted()) {
+ setError({
+ type: UserMediaErrorType.STREAM_INACTIVE,
+ nativeError: new Error('The camera stream was closed.'),
+ });
+ setIsLoading(false);
+ }
};
const retry = useCallback(() => {
@@ -256,77 +257,77 @@ export function useUserMedia(
}
}, [error, isLoading]);
- useEffect(() => {
- if (error || isLoading || deepEqual(lastConstraintsApplied, constraints)) {
- return;
+ const getUserMedia = useCallback(async () => {
+ setIsLoading(true);
+ if (stream) {
+ stream.removeEventListener('inactive', onStreamInactive);
+ stream.getTracks().forEach((track) => track.stop());
}
- setLastConstraintsApplied(constraints);
-
- const getUserMedia = async () => {
- setIsLoading(true);
- if (stream) {
- stream.removeEventListener('inactive', onStreamInactive);
- stream.getTracks().forEach((track) => track.stop());
- }
- const deviceDetails = await analyzeCameraDevices(constraints);
- const updatedConstraints = {
- ...constraints,
- video: {
- ...(constraints ? (constraints.video as MediaTrackConstraints) : {}),
- deviceId: { exact: deviceDetails.validDeviceIds },
- },
- };
- const str = await navigator.mediaDevices.getUserMedia(updatedConstraints);
- str?.addEventListener('inactive', onStreamInactive);
- if (isActive.current) {
- setStream(str);
- setDimensions(getStreamDimensions(str, true));
- setIsLoading(false);
- setAvailableCameraDevices(deviceDetails.availableDevices);
- setSelectedCameraDeviceId(getStreamDeviceId(str));
- }
+ const deviceDetails = await analyzeCameraDevices(constraints);
+ const updatedConstraints = {
+ ...constraints,
+ video: {
+ ...(constraints ? (constraints.video as MediaTrackConstraints) : {}),
+ deviceId: { exact: deviceDetails.validDeviceIds },
+ },
};
- const getCameraPermissionState = async () => {
- try {
- return await navigator.permissions.query({
- name: 'camera' as PermissionName,
- });
- } catch (err) {
- return null;
+ const str = await navigator.mediaDevices.getUserMedia(updatedConstraints);
+ str?.addEventListener('inactive', onStreamInactive);
+ if (isMounted()) {
+ setStream(str);
+ setDimensions(getStreamDimensions(str, true));
+ setIsLoading(false);
+ setAvailableCameraDevices(deviceDetails.availableDevices);
+ setSelectedCameraDeviceId(getStreamDeviceId(str));
+ }
+ return str;
+ }, [stream, constraints]);
+
+ const getCameraPermissionState = async () => {
+ try {
+ return await navigator.permissions.query({
+ name: 'camera' as PermissionName,
+ });
+ } catch (err) {
+ return null;
+ }
+ };
+
+ useEffect(() => {
+ if (videoRef) {
+ if (error || isLoading || deepEqual(lastConstraintsApplied, constraints)) {
+ return;
}
- };
- getUserMedia()
- .catch((err) => {
- return Promise.all([err, getCameraPermissionState()]);
- })
- .then((result) => {
- if (!result) {
- return Promise.all([null, getCameraPermissionState()]);
- }
- return result;
- })
- .then(([err, cameraPermission]) => {
- cameraPermissionState = cameraPermission?.state ?? null;
- if (err && isActive.current) {
- handleGetUserMediaError(err);
- throw err;
+ setLastConstraintsApplied(constraints);
+
+ const effect = async () => {
+ try {
+ await getUserMedia();
+ } catch (err) {
+ const permissionState = (await getCameraPermissionState())?.state ?? null;
+ if (err && isMounted()) {
+ handleGetUserMediaError(err, permissionState);
+ throw err;
+ }
}
- })
- .catch(handleError);
- }, [constraints, stream, error, isLoading, lastConstraintsApplied]);
+ };
+ effect().catch(handleError);
+ }
+ }, [constraints, stream, error, isLoading, lastConstraintsApplied, getUserMedia, videoRef]);
useEffect(() => {
- if (stream && videoRef.current) {
+ if (stream && videoRef && videoRef.current) {
// eslint-disable-next-line no-param-reassign
videoRef.current.onresize = () => {
- if (isActive.current) {
+ if (isMounted()) {
setDimensions(getStreamDimensions(stream, false));
}
};
}
- }, [stream]);
+ }, [stream, videoRef]);
return useObjectMemo({
+ getUserMedia,
stream,
dimensions,
error,
diff --git a/packages/camera-web/src/hooks/index.ts b/packages/camera-web/src/hooks/index.ts
new file mode 100644
index 000000000..018b700d8
--- /dev/null
+++ b/packages/camera-web/src/hooks/index.ts
@@ -0,0 +1 @@
+export * from './useCameraPermission';
diff --git a/packages/camera-web/src/hooks/useCameraPermission.ts b/packages/camera-web/src/hooks/useCameraPermission.ts
new file mode 100644
index 000000000..252721b14
--- /dev/null
+++ b/packages/camera-web/src/hooks/useCameraPermission.ts
@@ -0,0 +1,38 @@
+import { useCallback, useMemo } from 'react';
+import { isMobileDevice, useObjectMemo } from '@monkvision/common';
+import { CameraResolution } from '@monkvision/types';
+import { CameraFacingMode } from '../Camera';
+import { getMediaConstraints } from '../Camera/hooks/utils';
+import { useUserMedia } from '../Camera/hooks';
+
+/**
+ * Handle used to request camera permission on the user device.
+ */
+export interface CameraPermissionHandle {
+ /**
+ * Callback that can be used to request the camera permission on the current device.
+ */
+ requestCameraPermission: () => Promise;
+}
+
+/**
+ * Custom hook that can be used to request the camera permissions on the current device.
+ */
+export function useCameraPermission(): CameraPermissionHandle {
+ const contraints = useMemo(
+ () =>
+ getMediaConstraints({
+ resolution: isMobileDevice() ? CameraResolution.UHD_4K : CameraResolution.FHD_1080P,
+ facingMode: CameraFacingMode.ENVIRONMENT,
+ }),
+ [],
+ );
+ const { getUserMedia } = useUserMedia(contraints, null);
+
+ const requestCameraPermission = useCallback(async () => {
+ const stream = await getUserMedia();
+ stream.getTracks().forEach((track) => track.stop());
+ }, [getUserMedia]);
+
+ return useObjectMemo({ requestCameraPermission });
+}
diff --git a/packages/camera-web/src/index.ts b/packages/camera-web/src/index.ts
index 53fb1316e..1763495a4 100644
--- a/packages/camera-web/src/index.ts
+++ b/packages/camera-web/src/index.ts
@@ -1,4 +1,5 @@
export * from './Camera';
export * from './SimpleCameraHUD';
+export * from './hooks';
export * from './utils';
export * from './i18n';
diff --git a/packages/camera-web/test/Camera/Camera.test.tsx b/packages/camera-web/test/Camera/Camera.test.tsx
index 64d78fef7..bb2149b46 100644
--- a/packages/camera-web/test/Camera/Camera.test.tsx
+++ b/packages/camera-web/test/Camera/Camera.test.tsx
@@ -219,6 +219,8 @@ describe('Camera component', () => {
expectPropsOnChildMock(HUDComponent as jest.Mock, {
handle: {
takePicture: useTakePictureResultMock.takePicture,
+ getImageData: expect.any(Function),
+ compressImage: expect.any(Function),
error: useCameraPreviewResultMock.error,
retry: useCameraPreviewResultMock.retry,
isLoading: useCameraPreviewResultMock.isLoading || useTakePictureResultMock.isLoading,
@@ -226,6 +228,19 @@ describe('Camera component', () => {
},
cameraPreview: expect.anything(),
});
+ const takeScreenshotMock = (useCameraScreenshot as jest.Mock).mock.results[0].value;
+ const compressMock = (useCompression as jest.Mock).mock.results[0].value;
+ const { getImageData, compressImage } = (HUDComponent as jest.Mock).mock.calls[0][0].handle;
+
+ expect(takeScreenshotMock).not.toHaveBeenCalled();
+ getImageData();
+ expect(takeScreenshotMock).toHaveBeenCalledWith();
+
+ expect(compressMock).not.toHaveBeenCalled();
+ const value = { test: 'test' };
+ compressImage(value);
+ expect(compressMock).toHaveBeenCalledWith(value);
+
unmount();
});
diff --git a/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts b/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts
index 7bce598cc..459cf0905 100644
--- a/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts
+++ b/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts
@@ -172,5 +172,17 @@ describe('useCameraScreenshot hook', () => {
);
unmount();
});
+
+ it('should work properly without any monitoring as parameters', () => {
+ const { result, unmount } = renderHook(useCameraScreenshot, {
+ initialProps: { videoRef, canvasRef, dimensions },
+ });
+
+ expect(result.current()).toEqual({
+ data: expect.any(Array),
+ });
+
+ unmount();
+ });
});
});
diff --git a/packages/camera-web/test/Camera/hooks/useCompression.test.ts b/packages/camera-web/test/Camera/hooks/useCompression.test.ts
index ebaa0029b..5548483a5 100644
--- a/packages/camera-web/test/Camera/hooks/useCompression.test.ts
+++ b/packages/camera-web/test/Camera/hooks/useCompression.test.ts
@@ -187,5 +187,18 @@ describe('useCompression hook', () => {
);
unmount();
});
+
+ it('should work properly without any monitoring parameter', async () => {
+ const canvasRef = {} as RefObject;
+ const options = { format: CompressionFormat.JPEG, quality: 0.6 };
+
+ const { result, unmount } = renderHook(useCompression, {
+ initialProps: { canvasRef, options },
+ });
+
+ const value = await result.current(mockImageData);
+ expect(value.blob).toBeDefined();
+ unmount();
+ });
});
});
diff --git a/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts
index d18c6e1c0..0b6f4b992 100644
--- a/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts
+++ b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts
@@ -18,11 +18,13 @@ import { createFakePromise } from '@monkvision/test-utils';
function renderUseUserMedia(initialProps: {
constraints: MediaStreamConstraints;
- videoRef: RefObject;
+ videoRef: RefObject | null;
}) {
return renderHook(
- (props: { constraints: MediaStreamConstraints; videoRef: RefObject }) =>
- useUserMedia(props.constraints, props.videoRef),
+ (props: {
+ constraints: MediaStreamConstraints;
+ videoRef: RefObject | null;
+ }) => useUserMedia(props.constraints, props.videoRef),
{ initialProps },
);
}
@@ -45,6 +47,17 @@ describe('useUserMedia hook', () => {
jest.clearAllMocks();
});
+ it('should not call getUserMedia if the videoRef is null', async () => {
+ const constraints: MediaStreamConstraints = {
+ audio: false,
+ video: { width: 123, height: 456 },
+ };
+ const { unmount } = renderUseUserMedia({ constraints, videoRef: null });
+ expect(analyzeCameraDevices).not.toHaveBeenCalled();
+ expect(gumMock?.getUserMediaSpy).not.toHaveBeenCalled();
+ unmount();
+ });
+
it('should make a call to the getUserMedia with the given constraints', async () => {
const videoRef = { current: {} } as RefObject;
const constraints: MediaStreamConstraints = {
@@ -66,6 +79,32 @@ describe('useUserMedia hook', () => {
unmount();
});
+ it('should make a call to the getUserMedia with the given constraints (case when videoRef null)', async () => {
+ const constraints: MediaStreamConstraints = {
+ audio: false,
+ video: { width: 123, height: 456 },
+ };
+ const { result, unmount } = renderUseUserMedia({ constraints, videoRef: null });
+ expect(analyzeCameraDevices).not.toHaveBeenCalled();
+ expect(gumMock?.getUserMediaSpy).not.toHaveBeenCalled();
+ await act(async () => {
+ const stream = await result.current.getUserMedia();
+ expect(stream).toEqual(gumMock?.stream);
+ });
+ await waitFor(() => {
+ expect(analyzeCameraDevices).toHaveBeenCalled();
+ expect(gumMock?.getUserMediaSpy).toHaveBeenCalledTimes(1);
+ expect(gumMock?.getUserMediaSpy).toHaveBeenCalledWith({
+ ...constraints,
+ video: {
+ ...(constraints?.video as any),
+ deviceId: { exact: validDeviceIds },
+ },
+ });
+ });
+ unmount();
+ });
+
it('should return the stream obtained with getUserMedia in case of success', async () => {
const videoRef = { current: {} } as RefObject;
const constraints: MediaStreamConstraints = {
@@ -76,6 +115,7 @@ describe('useUserMedia hook', () => {
const settings = gumMock?.tracks[0].getSettings();
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: gumMock?.stream,
dimensions: { width: settings?.width, height: settings?.height },
error: null,
@@ -117,6 +157,7 @@ describe('useUserMedia hook', () => {
const { result, unmount } = renderUseUserMedia({ constraints: {}, videoRef });
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: null,
dimensions: null,
error: {
@@ -143,6 +184,7 @@ describe('useUserMedia hook', () => {
const { result } = renderUseUserMedia({ constraints: {}, videoRef });
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: null,
dimensions: null,
error: {
@@ -168,6 +210,7 @@ describe('useUserMedia hook', () => {
const { result } = renderUseUserMedia({ constraints: {}, videoRef });
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: null,
dimensions: null,
error: {
@@ -188,6 +231,7 @@ describe('useUserMedia hook', () => {
const { result, unmount } = renderUseUserMedia({ constraints: {}, videoRef });
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: null,
dimensions: null,
error: {
@@ -223,6 +267,7 @@ describe('useUserMedia hook', () => {
const { result, unmount } = renderUseUserMedia({ constraints: {}, videoRef });
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: null,
dimensions: null,
error: {
@@ -257,6 +302,7 @@ describe('useUserMedia hook', () => {
// eslint-disable-next-line no-await-in-loop
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: null,
dimensions: null,
error: {
@@ -282,6 +328,7 @@ describe('useUserMedia hook', () => {
const { result, unmount } = renderUseUserMedia({ constraints: {}, videoRef });
await waitFor(() => {
expect(result.current).toEqual({
+ getUserMedia: expect.any(Function),
stream: null,
dimensions: null,
error: {
diff --git a/packages/camera-web/test/hooks/useCameraPermission.test.ts b/packages/camera-web/test/hooks/useCameraPermission.test.ts
new file mode 100644
index 000000000..8cab90102
--- /dev/null
+++ b/packages/camera-web/test/hooks/useCameraPermission.test.ts
@@ -0,0 +1,69 @@
+const stop = jest.fn();
+const constraints = { test: 'hello' };
+
+jest.mock('../../src/Camera/hooks', () => ({
+ ...jest.requireActual('../../src/Camera/hooks'),
+ useUserMedia: jest.fn(() => ({
+ getUserMedia: jest.fn(() =>
+ Promise.resolve({
+ getTracks: jest.fn(() => [{ stop }, { stop }]),
+ }),
+ ),
+ })),
+}));
+jest.mock('../../src/Camera/hooks/utils', () => ({
+ ...jest.requireActual('../../src/Camera/hooks/utils'),
+ getMediaConstraints: jest.fn(() => constraints),
+}));
+
+import { CameraResolution } from '@monkvision/types';
+import { renderHook } from '@testing-library/react-hooks';
+import { isMobileDevice } from '@monkvision/common';
+import { CameraFacingMode, useCameraPermission } from '../../src';
+import { useUserMedia } from '../../src/Camera/hooks';
+import { getMediaConstraints } from '../../src/Camera/hooks/utils';
+
+describe('useCameraPermission hook', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should make a call to the getUserMedia function when asking for permissions', async () => {
+ (isMobileDevice as jest.Mock).mockImplementationOnce(() => true);
+ const { result, unmount } = renderHook(useCameraPermission);
+
+ expect(getMediaConstraints).toHaveBeenCalledWith({
+ resolution: CameraResolution.UHD_4K,
+ facingMode: CameraFacingMode.ENVIRONMENT,
+ });
+ expect(useUserMedia).toHaveBeenCalledWith(constraints, null);
+ const { getUserMedia } = (useUserMedia as jest.Mock).mock.results[0].value;
+
+ expect(getUserMedia).not.toHaveBeenCalled();
+ await result.current.requestCameraPermission();
+ expect(getUserMedia).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should switch to FHD_1080P in desktop', async () => {
+ (isMobileDevice as jest.Mock).mockImplementationOnce(() => false);
+ const { unmount } = renderHook(useCameraPermission);
+
+ expect(getMediaConstraints).toHaveBeenCalledWith({
+ resolution: CameraResolution.FHD_1080P,
+ facingMode: CameraFacingMode.ENVIRONMENT,
+ });
+
+ unmount();
+ });
+
+ it('should stop the stream after fetching it', async () => {
+ const { result, unmount } = renderHook(useCameraPermission);
+
+ await result.current.requestCameraPermission();
+ expect(stop).toHaveBeenCalledTimes(2);
+
+ unmount();
+ });
+});
diff --git a/packages/common-ui-web/README.md b/packages/common-ui-web/README.md
index 6382fc62d..8fae5a264 100644
--- a/packages/common-ui-web/README.md
+++ b/packages/common-ui-web/README.md
@@ -351,7 +351,7 @@ for more details.
| Prop | Type | Description | Required | Default Value |
|-------------|---------------------------------|-----------------------------------------------------------------------|----------|---------------|
| id | string | The ID of the application Live Config. | ✔️ | |
-| localConfig | CaptureAppConfig | Use this prop to configure a configuration on your local environment. | | |
+| localConfig | PhotoCaptureAppConfig | Use this prop to configure a configuration on your local environment. | | |
| lang | string | null | The language used by this component. | | `en` |
---
@@ -389,6 +389,31 @@ function LoginPage() {
---
+## RecordVideoButton
+### Description
+Button used on the VideoCapture component, displayed on top of the camera preview to allow the user to record a video.
+
+### Example
+```tsx
+import { useState } from 'react';
+import { RecordVideoButton } from '@monkvision/common-ui-web';
+
+function App() {
+ const [isRecording, setIsRecording] = useState(false);
+ return setIsRecording((v) => !v)} />;
+}
+```
+
+### Props
+| Prop | Type | Description | Required | Default Value |
+|-----------------|---------------------------------------------------|-----------------------------------------------------------------------|----------|---------------|
+| size | number | The size of the button in pixels. | | `80` |
+| isRecording | boolean | Boolean indicating if the user is currently recording a video or not. | | `false` |
+| tooltip | string | Optional tooltip that will be displayed around the button. | | |
+| tooltipPosition | `'up' | 'down' | 'right' | 'left'` | The position of the tooltip around the button. | | `'up'` |
+
+---
+
## SightOverlay
### Description
A component that displays the SVG overlay of the given sight. The SVG element can be customized the exact same way as
@@ -624,22 +649,8 @@ function VehicleSelectionPage() {
| inspectionId | string | The ID of the inspection. | | |
| apiDomain | string | The domain of the Monk API. | | |
| authToken | string | The authentication token used to communicate with the API. | | |
-## VehiclePartSelection
-I shows the collections of VehicleDynamicWireframe and we can switch between 4 different views front left, front right, rear left and rear right.
-### Example
-```tsx
-function Component() {
- return console.log(p)} />
-}
-```
-### Props
-| Prop | Type | Description | Required| Default Value|
-|-----------------|--------------------------------|-----------------------------------------------------------------------------------------|---------|--------------|
-| vehicleModel | VehicleModel | Initial vehicle model. | ✔️ | |
-| orientation | PartSelectionOrientation | Orientation where the vehicle want to face. | | front-left |
-| onPartsSelected | (parts: VehiclePart[]) => void | Callback called when update selected parts. | | |
+
+---
## VehicleDynamicWireframe
For the given Vehicle Model and orientation. It shows the wireframe on the view and we can able to select it.
@@ -675,3 +686,40 @@ getPartAttributes
| onClickPart | (part: VehiclePart) => void | Callback called when a part is clicked. | | |
| getPartAttributes | (part: VehiclePart) => SVGProps | Custom function for HTML attributes to give to the tags based on part. | | |
+---
+
+## VehicleWalkaroundIndicator
+Component used to display a position indicator to the user when they are walking around their vehicle in the
+VideoCapture process.
+
+### Example
+```tsx
+import { useState } from 'react';
+import { useDeviceOrientation } from '@monkvision/common';
+import { Button, VehicleWalkaroundIndicator } from '@monkvision/common-ui-web';
+
+function TestComponent() {
+ const [startingAlpha, setStartingAlpha] = useState(null);
+ const { alpha, requestCompassPermission, isPermissionGranted } = useDeviceOrientation();
+
+ if (!isPermissionGranted) {
+ return ;
+ }
+
+ if (startingAlpha === null) {
+ return ;
+ }
+
+ const diff = startingAlpha - alpha;
+ const rotation = diff < 0 ? 360 + diff : diff;
+
+ return (
+
+ );
+}
+```
+### Props
+| Prop | Type | Description | Required | Default Value |
+|-------|--------|------------------------------------------------------------------|----------|---------------|
+| alpha | number | The rotation of the user around the vehicle (between 0 and 359). | ✔️ | |
+| size | number | The size of the indicator in pixels. | | 60 |
diff --git a/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.styles.ts b/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.styles.ts
index 85e054535..8beb35032 100644
--- a/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.styles.ts
+++ b/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.styles.ts
@@ -27,10 +27,12 @@ export const styles: Styles = {
},
message: {
padding: '0 30px 30px 30px',
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
fontSize: 18,
+ wordBreak: 'break-word',
+ textAlign: 'center',
+ },
+ messageNoIcon: {
+ paddingTop: 30,
},
buttonsContainer: {
width: '100%',
diff --git a/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.tsx b/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.tsx
index e37a6d852..e900537e2 100644
--- a/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.tsx
+++ b/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.tsx
@@ -25,6 +25,7 @@ export function BackdropDialog({
const style = useBackdropDialogStyles({
backdropOpacity,
showCancelButton,
+ dialogIcon,
});
return show ? (
diff --git a/packages/common-ui-web/src/components/BackdropDialog/hooks.ts b/packages/common-ui-web/src/components/BackdropDialog/hooks.ts
index 947549ae9..4774fbdb1 100644
--- a/packages/common-ui-web/src/components/BackdropDialog/hooks.ts
+++ b/packages/common-ui-web/src/components/BackdropDialog/hooks.ts
@@ -69,7 +69,8 @@ export interface BackdropDialogProps {
}
export function useBackdropDialogStyles(
- props: Required
>,
+ props: Required> &
+ Pick,
) {
const { palette } = useMonkTheme();
@@ -80,6 +81,7 @@ export function useBackdropDialogStyles(
},
dialog: {
...styles['dialog'],
+ ...(!props.dialogIcon ? styles['messageNoIcon'] : {}),
backgroundColor: palette.background.dark,
},
cancelButton: {
diff --git a/packages/common-ui-web/src/components/CreateInspection/CreateInspection.styles.ts b/packages/common-ui-web/src/components/CreateInspection/CreateInspection.styles.ts
index 4c1231c01..b4f9d6b7f 100644
--- a/packages/common-ui-web/src/components/CreateInspection/CreateInspection.styles.ts
+++ b/packages/common-ui-web/src/components/CreateInspection/CreateInspection.styles.ts
@@ -11,6 +11,7 @@ export const styles: Styles = {
},
errorMessage: {
textAlign: 'center',
+ padding: '0 16px 16px 16px',
},
retryButtonContainer: {
paddingTop: 20,
diff --git a/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx b/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx
index c9cfb3e9e..5644b4bf2 100644
--- a/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx
+++ b/packages/common-ui-web/src/components/LiveConfigAppProvider/LiveConfigAppProvider.tsx
@@ -6,7 +6,7 @@ import {
useLoadingState,
} from '@monkvision/common';
import { PropsWithChildren, useState } from 'react';
-import { CaptureAppConfig } from '@monkvision/types';
+import { LiveConfig } from '@monkvision/types';
import { MonkApi } from '@monkvision/network';
import { useMonitoring } from '@monkvision/monitoring';
import { styles } from './LiveConfigAppProvider.styles';
@@ -25,7 +25,7 @@ export interface LiveConfigAppProviderProps extends Omit) {
useI18nSync(lang);
const loading = useLoadingState(true);
- const [config, setConfig] = useState(null);
+ const [config, setConfig] = useState(null);
const { handleError } = useMonitoring();
const [retry, setRetry] = useState(0);
@@ -64,7 +64,7 @@ export function LiveConfigAppProvider({
},
[id, localConfig, retry],
{
- onResolve: (result) => {
+ onResolve: (result: LiveConfig) => {
loading.onSuccess();
setConfig(result);
},
diff --git a/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts
new file mode 100644
index 000000000..c74eac671
--- /dev/null
+++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts
@@ -0,0 +1,205 @@
+import { InteractiveStatus, Styles } from '@monkvision/types';
+import { CSSProperties, useMemo, useState } from 'react';
+import {
+ changeAlpha,
+ getInteractiveVariants,
+ InteractiveVariation,
+ useInterval,
+ useIsMounted,
+ useMonkTheme,
+} from '@monkvision/common';
+import { MonkRecordVideoButtonProps } from './RecordVideoButton.types';
+import { TAKE_PICTURE_BUTTON_COLORS } from '../TakePictureButton/TakePictureButton.styles';
+
+export const RECORD_VIDEO_BUTTON_RECORDING_COLORS = getInteractiveVariants(
+ '#cb0000',
+ InteractiveVariation.DARKEN,
+);
+const BORDER_WIDTH_RATIO = 0.05;
+const INNER_CIRCLE_DEFAULT_RATIO = 0.5;
+const INNER_CIRCLE_SMALL_RATIO = 0.3;
+const INNER_CIRCLE_BIG_RATIO = 0.7;
+const TOOLTIP_MAX_WIDTH_RATIO = 2;
+const TOOLTIP_ARROW_RATIO = 0.15;
+const TOOLTIP_MARGIN_RATIO = 0.1;
+const RECORDING_ANIMATION_DURATION_MS = 1200;
+
+export const styles: Styles = {
+ button: {
+ position: 'relative',
+ borderStyle: 'solid',
+ borderRadius: '50%',
+ cursor: 'pointer',
+ boxSizing: 'border-box',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 0,
+ margin: 0,
+ },
+ buttonDisabled: {
+ opacity: 0.75,
+ cursor: 'default',
+ },
+ innerCircle: {
+ borderRadius: '50%',
+ },
+ tooltip: {
+ position: 'absolute',
+ zIndex: 2,
+ pointerEvents: 'none',
+ padding: '10px 6px',
+ borderRadius: 5,
+ fontSize: 13,
+ textAlign: 'center',
+ width: 'max-content',
+ },
+ tooltipArrow: {
+ position: 'absolute',
+ zIndex: 2,
+ pointerEvents: 'none',
+ borderStyle: 'solid',
+ },
+};
+
+interface RecordVideoButtonStyleParams
+ extends Required> {
+ status: InteractiveStatus;
+}
+
+interface RecordVideoButtonStyles {
+ container: CSSProperties;
+ innerCircle: CSSProperties;
+ tooltipContainer: CSSProperties;
+ tooltipArrow: CSSProperties;
+}
+
+function getTooltipPosition(
+ position: 'up' | 'down' | 'left' | 'right',
+ size: number,
+ tooltipBg: string,
+): { tooltip: CSSProperties; arrow: CSSProperties } {
+ switch (position) {
+ case 'up':
+ return {
+ tooltip: {
+ top: -size * (BORDER_WIDTH_RATIO + TOOLTIP_ARROW_RATIO + TOOLTIP_MARGIN_RATIO),
+ left: -(size * BORDER_WIDTH_RATIO),
+ transform: `translateX(calc(${size / 2}px - 50%)) translateY(-100%)`,
+ },
+ arrow: {
+ top: -size * (BORDER_WIDTH_RATIO + TOOLTIP_ARROW_RATIO + TOOLTIP_MARGIN_RATIO),
+ borderWidth: size * TOOLTIP_ARROW_RATIO,
+ borderColor: `${tooltipBg} transparent transparent transparent`,
+ },
+ };
+ case 'down':
+ return {
+ tooltip: {
+ top: size * (1 - BORDER_WIDTH_RATIO + TOOLTIP_ARROW_RATIO + TOOLTIP_MARGIN_RATIO),
+ left: -(size * BORDER_WIDTH_RATIO),
+ transform: `translateX(calc(${size / 2}px - 50%))`,
+ },
+ arrow: {
+ top: size * (1 - 2 * BORDER_WIDTH_RATIO),
+ borderWidth: size * TOOLTIP_ARROW_RATIO,
+ borderColor: `transparent transparent ${tooltipBg} transparent`,
+ },
+ };
+ case 'left':
+ return {
+ tooltip: {
+ top: -(size * BORDER_WIDTH_RATIO),
+ left: -size * (BORDER_WIDTH_RATIO + TOOLTIP_ARROW_RATIO + TOOLTIP_MARGIN_RATIO),
+ transform: `translateX(-100%) translateY(calc(${size / 2}px - 50%))`,
+ },
+ arrow: {
+ left: -size * (BORDER_WIDTH_RATIO + TOOLTIP_ARROW_RATIO + TOOLTIP_MARGIN_RATIO),
+ borderWidth: size * TOOLTIP_ARROW_RATIO,
+ borderColor: `transparent transparent transparent ${tooltipBg}`,
+ },
+ };
+ case 'right':
+ return {
+ tooltip: {
+ top: -(size * BORDER_WIDTH_RATIO),
+ left: size * (1 - BORDER_WIDTH_RATIO + TOOLTIP_ARROW_RATIO + TOOLTIP_MARGIN_RATIO),
+ transform: `translateY(calc(${size / 2}px - 50%))`,
+ },
+ arrow: {
+ left: size * (1 - 2 * BORDER_WIDTH_RATIO),
+ borderWidth: size * TOOLTIP_ARROW_RATIO,
+ borderColor: `transparent ${tooltipBg} transparent transparent`,
+ },
+ };
+ default:
+ return { tooltip: {}, arrow: {} };
+ }
+}
+
+export function useRecordVideoButtonStyles({
+ size,
+ isRecording,
+ status,
+ tooltipPosition,
+}: RecordVideoButtonStyleParams): RecordVideoButtonStyles {
+ const { palette } = useMonkTheme();
+ const [animationRatio, setAnimationRatio] = useState(INNER_CIRCLE_SMALL_RATIO);
+ const isMounted = useIsMounted();
+
+ useInterval(
+ () => {
+ if (isMounted()) {
+ setAnimationRatio((value) =>
+ value === INNER_CIRCLE_SMALL_RATIO ? INNER_CIRCLE_BIG_RATIO : INNER_CIRCLE_SMALL_RATIO,
+ );
+ }
+ },
+ isRecording ? RECORDING_ANIMATION_DURATION_MS : null,
+ );
+
+ const colors = useMemo(
+ () => ({
+ tooltipBg: changeAlpha(palette.surface.dark, 0.5),
+ }),
+ [],
+ );
+
+ const innerCircleSize = (isRecording ? animationRatio : INNER_CIRCLE_DEFAULT_RATIO) * size;
+ const innerCircleBackgroundColor = isRecording
+ ? RECORD_VIDEO_BUTTON_RECORDING_COLORS[status]
+ : TAKE_PICTURE_BUTTON_COLORS[status];
+ const position = getTooltipPosition(tooltipPosition, size, colors.tooltipBg);
+
+ return {
+ container: {
+ ...styles['button'],
+ ...(status === InteractiveStatus.DISABLED ? styles['buttonDisabled'] : {}),
+ borderWidth: size * BORDER_WIDTH_RATIO,
+ borderColor: TAKE_PICTURE_BUTTON_COLORS[status],
+ width: size,
+ height: size,
+ },
+ innerCircle: {
+ ...styles['innerCircle'],
+ backgroundColor: innerCircleBackgroundColor,
+ width: innerCircleSize,
+ height: innerCircleSize,
+ transition: isRecording
+ ? `width ${RECORDING_ANIMATION_DURATION_MS}ms linear, height ${RECORDING_ANIMATION_DURATION_MS}ms linear`
+ : '',
+ },
+ tooltipContainer: {
+ ...styles['tooltip'],
+ ...position.tooltip,
+ backgroundColor: colors.tooltipBg,
+ color: palette.surface.light,
+ maxWidth: size * TOOLTIP_MAX_WIDTH_RATIO,
+ },
+ tooltipArrow: {
+ ...styles['tooltipArrow'],
+ ...position.arrow,
+ },
+ };
+}
diff --git a/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.tsx b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.tsx
new file mode 100644
index 000000000..057290061
--- /dev/null
+++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.tsx
@@ -0,0 +1,60 @@
+import { forwardRef } from 'react';
+import { useInteractiveStatus } from '@monkvision/common';
+import { useRecordVideoButtonStyles } from './RecordVideoButton.styles';
+import { RecordVideoButtonProps } from './RecordVideoButton.types';
+
+/**
+ * Button used on the VideoCapture component, displayed on top of the camera preview to allow the user to record a
+ * video.
+ */
+export const RecordVideoButton = forwardRef(
+ function RecordVideoButton(
+ {
+ size = 80,
+ isRecording = false,
+ disabled = false,
+ tooltip,
+ tooltipPosition = 'up',
+ style = {},
+ onClick,
+ onMouseEnter,
+ onMouseLeave,
+ onMouseDown,
+ onMouseUp,
+ ...passThroughProps
+ },
+ ref,
+ ) {
+ const { status, eventHandlers } = useInteractiveStatus({
+ disabled,
+ componentHandlers: {
+ onMouseEnter,
+ onMouseLeave,
+ onMouseDown,
+ onMouseUp,
+ },
+ });
+ const { container, innerCircle, tooltipContainer, tooltipArrow } = useRecordVideoButtonStyles({
+ size,
+ isRecording,
+ status,
+ tooltipPosition,
+ });
+
+ return (
+
+ );
+ },
+);
diff --git a/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts
new file mode 100644
index 000000000..efc37dee8
--- /dev/null
+++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts
@@ -0,0 +1,37 @@
+import { ButtonHTMLAttributes } from 'react';
+
+/**
+ * Additional props that can be passed to the RecordVideoButton component.
+ */
+export interface MonkRecordVideoButtonProps {
+ /**
+ * This size includes the center circle + the outer rim, not just the circle at the middle.
+ *
+ * @default 80
+ */
+ size?: number;
+ /**
+ * Boolean indicating if the user is currently recording a video or not.
+ */
+ isRecording?: boolean;
+ /**
+ * Optional tooltip that will be displayed around the button.
+ */
+ tooltip?: string;
+ /**
+ * The position of the tooltip around the button.
+ *
+ * @default 'up'
+ */
+ tooltipPosition?: 'up' | 'down' | 'right' | 'left';
+ /**
+ * Callback called when the user clicks on the button.
+ */
+ onClick?: () => void;
+}
+
+/**
+ * Props that the TakePictureButton component can accept.
+ */
+export type RecordVideoButtonProps = MonkRecordVideoButtonProps &
+ ButtonHTMLAttributes;
diff --git a/packages/common-ui-web/src/components/RecordVideoButton/index.ts b/packages/common-ui-web/src/components/RecordVideoButton/index.ts
new file mode 100644
index 000000000..121232aa3
--- /dev/null
+++ b/packages/common-ui-web/src/components/RecordVideoButton/index.ts
@@ -0,0 +1,5 @@
+export { RecordVideoButton } from './RecordVideoButton';
+export {
+ type RecordVideoButtonProps,
+ type MonkRecordVideoButtonProps,
+} from './RecordVideoButton.types';
diff --git a/packages/common-ui-web/src/components/TakePictureButton/TakePictureButton.styles.ts b/packages/common-ui-web/src/components/TakePictureButton/TakePictureButton.styles.ts
index 809993727..ef69d472c 100644
--- a/packages/common-ui-web/src/components/TakePictureButton/TakePictureButton.styles.ts
+++ b/packages/common-ui-web/src/components/TakePictureButton/TakePictureButton.styles.ts
@@ -1,7 +1,7 @@
import { getInteractiveVariants, InteractiveVariation } from '@monkvision/common';
import { Styles } from '@monkvision/types';
-export const takePictureButtonColors = getInteractiveVariants(
+export const TAKE_PICTURE_BUTTON_COLORS = getInteractiveVariants(
'#f3f3f3',
InteractiveVariation.DARKEN,
);
diff --git a/packages/common-ui-web/src/components/TakePictureButton/hooks.ts b/packages/common-ui-web/src/components/TakePictureButton/hooks.ts
index d6f628a82..6cde555d6 100644
--- a/packages/common-ui-web/src/components/TakePictureButton/hooks.ts
+++ b/packages/common-ui-web/src/components/TakePictureButton/hooks.ts
@@ -1,6 +1,6 @@
import { InteractiveStatus } from '@monkvision/types';
import { CSSProperties, useState } from 'react';
-import { styles, takePictureButtonColors } from './TakePictureButton.styles';
+import { styles, TAKE_PICTURE_BUTTON_COLORS } from './TakePictureButton.styles';
/**
* Additional props that can be passed to the TakePictureButton component.
@@ -47,7 +47,7 @@ export function useTakePictureButtonStyle(
width: params.size - 2 * borderWidth,
height: params.size - 2 * borderWidth,
borderWidth,
- borderColor: takePictureButtonColors[InteractiveStatus.DEFAULT],
+ borderColor: TAKE_PICTURE_BUTTON_COLORS[InteractiveStatus.DEFAULT],
},
innerLayer: {
...styles['innerLayer'],
@@ -55,7 +55,7 @@ export function useTakePictureButtonStyle(
width: params.size * INNER_BUTTON_SIZE_RATIO,
height: params.size * INNER_BUTTON_SIZE_RATIO,
margin: borderWidth,
- backgroundColor: takePictureButtonColors[params.status],
+ backgroundColor: TAKE_PICTURE_BUTTON_COLORS[params.status],
border: 'none',
transform: isPressed ? 'scale(0.7)' : 'scale(1)',
transition: `transform ${PRESS_ANIMATION_DURATION_MS / 2}ms ease-in`,
diff --git a/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.styles.ts b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.styles.ts
new file mode 100644
index 000000000..557f186b2
--- /dev/null
+++ b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.styles.ts
@@ -0,0 +1,122 @@
+import { SVGProps } from 'react';
+import { VehicleWalkaroundIndicatorProps } from './VehicleWalkaroundIndicator.types';
+
+const PROGRESS_BAR_STROKE_WIDTH_RATIO = 0.15;
+const KNOB_STROKE_WIDTH_RATIO = 0.03;
+const DEFAULT_STEP_FILL_COLOR = '#6b6b6b';
+const NEXT_STEP_FILL_COLOR = '#f3f3f3';
+const PROGRESS_BAR_FILL_COLOR = '#18e700';
+const KNOB_FILL_COLOR = '#0A84FF';
+const KNOB_STROKE_COLOR = '#f3f3f3';
+
+interface VehicleWalkaroundIndicatorStyleParams extends Required {}
+
+interface IndicatorStep {
+ alpha: number;
+ props: SVGProps;
+}
+
+interface VehicleWalkaroundIndicatorStyles {
+ svgProps: SVGProps;
+ steps: IndicatorStep[];
+ progressBarProps: SVGProps;
+ knobProps: SVGProps;
+}
+
+function getDrawingConstants(size: number) {
+ const s = size * (1 - PROGRESS_BAR_STROKE_WIDTH_RATIO - KNOB_STROKE_WIDTH_RATIO);
+ const r = s / 2;
+ return {
+ s,
+ r,
+ v: (r * Math.SQRT2) / 2,
+ offset: (size / 2) * (KNOB_STROKE_WIDTH_RATIO + PROGRESS_BAR_STROKE_WIDTH_RATIO),
+ };
+}
+
+function getStepsProps({ alpha, size }: VehicleWalkaroundIndicatorStyleParams): IndicatorStep[] {
+ const { s, r, v, offset } = getDrawingConstants(size);
+ const sharedProps: SVGProps = {
+ r: (size * PROGRESS_BAR_STROKE_WIDTH_RATIO) / 2,
+ strokeWidth: 0,
+ fill: DEFAULT_STEP_FILL_COLOR,
+ };
+ const steps: IndicatorStep[] = [
+ { alpha: 0, cx: r, cy: s },
+ { alpha: 45, cx: r + v, cy: r + v },
+ { alpha: 90, cx: s, cy: r },
+ { alpha: 135, cx: r + v, cy: r - v },
+ { alpha: 180, cx: r, cy: 0 },
+ { alpha: 225, cx: r - v, cy: r - v },
+ { alpha: 270, cx: 0, cy: r },
+ { alpha: 315, cx: r - v, cy: r + v },
+ ].map((step) => ({
+ alpha: step.alpha,
+ props: {
+ cx: step.cx + offset,
+ cy: step.cy + offset,
+ ...sharedProps,
+ },
+ }));
+
+ const nextStep = steps?.find((step) => step.alpha > alpha);
+ if (nextStep) {
+ nextStep.props.fill = NEXT_STEP_FILL_COLOR;
+ }
+ return steps;
+}
+
+function getProgressBarProps({
+ alpha,
+ size,
+}: VehicleWalkaroundIndicatorStyleParams): SVGProps {
+ const { r, offset } = getDrawingConstants(size);
+ const circumference = r * 2 * Math.PI;
+ const dashSize = (alpha * circumference) / 360;
+
+ return {
+ r,
+ cx: offset + size / 2,
+ cy: size / 2,
+ strokeLinecap: 'round',
+ strokeWidth: size * PROGRESS_BAR_STROKE_WIDTH_RATIO,
+ stroke: PROGRESS_BAR_FILL_COLOR,
+ fill: 'none',
+ strokeDasharray: `${dashSize}px ${circumference - dashSize}px`,
+ transform: `scale(1 -1) translate(0 -${size + offset}) rotate(-90 ${offset + size / 2} ${
+ offset + size / 2
+ })`,
+ };
+}
+
+function getKnobProps({
+ alpha,
+ size,
+}: VehicleWalkaroundIndicatorStyleParams): SVGProps {
+ const { r, offset } = getDrawingConstants(size);
+ const theta = (alpha * Math.PI) / 180 - Math.PI / 2;
+ return {
+ r: (size * PROGRESS_BAR_STROKE_WIDTH_RATIO) / 2,
+ cx: offset + r * (1 + Math.cos(theta)),
+ cy: offset + r * (1 - Math.sin(theta)),
+ fill: KNOB_FILL_COLOR,
+ strokeWidth: size * KNOB_STROKE_WIDTH_RATIO,
+ stroke: KNOB_STROKE_COLOR,
+ };
+}
+
+export function useVehicleWalkaroundIndicatorStyles({
+ alpha,
+ size,
+}: VehicleWalkaroundIndicatorStyleParams): VehicleWalkaroundIndicatorStyles {
+ return {
+ svgProps: {
+ width: size,
+ height: size,
+ viewBox: `0 0 ${size} ${size}`,
+ },
+ steps: getStepsProps({ alpha, size }),
+ progressBarProps: getProgressBarProps({ alpha, size }),
+ knobProps: getKnobProps({ alpha, size }),
+ };
+}
diff --git a/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.tsx b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.tsx
new file mode 100644
index 000000000..4a9c60d9f
--- /dev/null
+++ b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.tsx
@@ -0,0 +1,23 @@
+import { VehicleWalkaroundIndicatorProps } from './VehicleWalkaroundIndicator.types';
+import { useVehicleWalkaroundIndicatorStyles } from './VehicleWalkaroundIndicator.styles';
+
+/**
+ * Component used to display a position indicator to the user when they are walking around their vehicle in the
+ * VideoCapture process.
+ */
+export function VehicleWalkaroundIndicator({ alpha, size = 60 }: VehicleWalkaroundIndicatorProps) {
+ const { svgProps, steps, progressBarProps, knobProps } = useVehicleWalkaroundIndicatorStyles({
+ alpha,
+ size,
+ });
+
+ return (
+
+ );
+}
diff --git a/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.types.ts b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.types.ts
new file mode 100644
index 000000000..462559ba0
--- /dev/null
+++ b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.types.ts
@@ -0,0 +1,15 @@
+/**
+ * Props accepted by the VehicleWalkaroundIndicator component.
+ */
+export interface VehicleWalkaroundIndicatorProps {
+ /**
+ * The rotation of the user around the vehicle.
+ */
+ alpha: number;
+ /**
+ * The size of the indicator in pixels.
+ *
+ * @default 60
+ */
+ size?: number;
+}
diff --git a/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/index.ts b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/index.ts
new file mode 100644
index 000000000..91fc75d17
--- /dev/null
+++ b/packages/common-ui-web/src/components/VehicleWalkaroundIndicator/index.ts
@@ -0,0 +1,2 @@
+export { VehicleWalkaroundIndicator } from './VehicleWalkaroundIndicator';
+export { type VehicleWalkaroundIndicatorProps } from './VehicleWalkaroundIndicator.types';
diff --git a/packages/common-ui-web/src/components/index.ts b/packages/common-ui-web/src/components/index.ts
index d983ad7b6..59784da71 100644
--- a/packages/common-ui-web/src/components/index.ts
+++ b/packages/common-ui-web/src/components/index.ts
@@ -8,6 +8,7 @@ export * from './ImageDetailedView';
export * from './InspectionGallery';
export * from './LiveConfigAppProvider';
export * from './Login';
+export * from './RecordVideoButton';
export * from './SightOverlay';
export * from './Slider';
export * from './Spinner';
@@ -18,3 +19,4 @@ export * from './VehicleDynamicWireframe';
export * from './VehiclePartSelection';
export * from './VehicleTypeAsset';
export * from './VehicleTypeSelection';
+export * from './VehicleWalkaroundIndicator';
diff --git a/packages/common-ui-web/src/icons/Icon.tsx b/packages/common-ui-web/src/icons/Icon.tsx
index 4e11afb4a..358a71fe7 100644
--- a/packages/common-ui-web/src/icons/Icon.tsx
+++ b/packages/common-ui-web/src/icons/Icon.tsx
@@ -1,4 +1,4 @@
-import { useMonkTheme } from '@monkvision/common';
+import { fullyColorSVG, useMonkTheme } from '@monkvision/common';
import { ColorProp } from '@monkvision/types';
import { SVGProps, useCallback } from 'react';
import { DynamicSVG } from '../components/DynamicSVG';
@@ -30,8 +30,6 @@ export interface IconProps extends Omit, 'width' | 'heig
primaryColor?: ColorProp;
}
-const COLOR_ATTRIBUTES = ['fill', 'stroke'];
-
function getSvg(icon: IconName): string {
const asset = MonkIconAssetsMap[icon];
if (!asset) {
@@ -52,18 +50,7 @@ export function Icon({
const { utils } = useMonkTheme();
const getAttributes = useCallback(
- (element: Element) => {
- return COLOR_ATTRIBUTES.reduce((customAttributes, colorAttribute) => {
- const attr = element.getAttribute(colorAttribute);
- if (attr && !['transparent', 'none'].includes(attr)) {
- return {
- ...customAttributes,
- [colorAttribute]: utils.getColor(primaryColor),
- };
- }
- return customAttributes;
- }, {});
- },
+ (element: Element) => fullyColorSVG(element, utils.getColor(primaryColor)),
[primaryColor, utils.getColor],
);
diff --git a/packages/common-ui-web/src/icons/assets.ts b/packages/common-ui-web/src/icons/assets.ts
index 5e3827502..fec451c76 100644
--- a/packages/common-ui-web/src/icons/assets.ts
+++ b/packages/common-ui-web/src/icons/assets.ts
@@ -66,9 +66,11 @@ export const MonkIconAssetsMap: IconAssetsMap = {
'camera':
'',
'camera-outline':
- '',
+ '',
'cancel':
'',
+ 'car-arrow':
+ '',
'cellular-signal-no-connection':
'',
'check-circle-outline':
@@ -83,12 +85,16 @@ export const MonkIconAssetsMap: IconAssetsMap = {
'',
'circle':
'',
+ 'circle-dot':
+ '',
'close':
'',
'cloud-download':
'',
'cloud-upload':
'',
+ 'compass-outline':
+ '',
'content-cut':
'',
'convertible':
diff --git a/packages/common-ui-web/src/icons/names.ts b/packages/common-ui-web/src/icons/names.ts
index 4b774f515..e39e5a8ac 100644
--- a/packages/common-ui-web/src/icons/names.ts
+++ b/packages/common-ui-web/src/icons/names.ts
@@ -35,6 +35,7 @@ export const iconNames = [
'camera',
'camera-outline',
'cancel',
+ 'car-arrow',
'cellular-signal-no-connection',
'check-circle-outline',
'check-circle',
@@ -42,9 +43,11 @@ export const iconNames = [
'chevron-left',
'chevron-right',
'circle',
+ 'circle-dot',
'close',
'cloud-download',
'cloud-upload',
+ 'compass-outline',
'content-cut',
'convertible',
'copy',
diff --git a/packages/common-ui-web/test/components/Checkbox.test.tsx b/packages/common-ui-web/test/components/Checkbox.test.tsx
index ad2f5dcc8..63afc0239 100644
--- a/packages/common-ui-web/test/components/Checkbox.test.tsx
+++ b/packages/common-ui-web/test/components/Checkbox.test.tsx
@@ -1,13 +1,12 @@
-import { changeAlpha } from '@monkvision/common';
-
jest.mock('../../src/icons', () => ({
Icon: jest.fn(() => <>>),
}));
import '@testing-library/jest-dom';
+import { changeAlpha } from '@monkvision/common';
import { fireEvent, render, screen } from '@testing-library/react';
-import { Checkbox, Icon } from '../../src';
import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { Checkbox, Icon } from '../../src';
const CHECKBOX_TEST_ID = 'checkbox-btn';
diff --git a/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx b/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx
index f8f472240..e2eb5c1fa 100644
--- a/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx
+++ b/packages/common-ui-web/test/components/LiveConfigAppProvider.test.tsx
@@ -1,4 +1,4 @@
-import { CaptureAppConfig } from '@monkvision/types';
+import { LiveConfig } from '@monkvision/types';
jest.mock('../../src/components/Button', () => ({
Button: jest.fn(() => <>>),
@@ -107,7 +107,7 @@ describe('LiveConfigAppProvider component', () => {
});
it('should not fetch the live config and return the local config if it is used', async () => {
- const localConfig = { hello: 'world' } as unknown as CaptureAppConfig;
+ const localConfig = { hello: 'world' } as unknown as LiveConfig;
const id = 'test-id-test';
const { unmount } = render();
diff --git a/packages/common-ui-web/test/components/RecordVideoButton.test.tsx b/packages/common-ui-web/test/components/RecordVideoButton.test.tsx
new file mode 100644
index 000000000..168578091
--- /dev/null
+++ b/packages/common-ui-web/test/components/RecordVideoButton.test.tsx
@@ -0,0 +1,172 @@
+import '@testing-library/jest-dom';
+import {
+ expectComponentToPassDownClassNameToHTMLElement,
+ expectComponentToPassDownOtherPropsToHTMLElement,
+ expectComponentToPassDownRefToHTMLElement,
+ expectComponentToPassDownStyleToHTMLElement,
+} from '@monkvision/test-utils';
+import { fireEvent, render, screen } from '@testing-library/react';
+import { RecordVideoButton } from '../../src';
+import { useInteractiveStatus } from '@monkvision/common';
+import { InteractiveStatus } from '@monkvision/types';
+
+const RECORD_VIDEO_BUTTON_TEST_ID = 'record-video-button';
+
+describe('RecordVideoButton component', () => {
+ it('should take the size prop into account', () => {
+ const size = 556;
+ const { unmount } = render();
+
+ const buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl).toHaveStyle({
+ boxSizing: 'border-box',
+ width: `${size}px`,
+ height: `${size}px`,
+ });
+
+ unmount();
+ });
+
+ it('should have a default size of 80', () => {
+ const { unmount } = render();
+
+ const buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl).toHaveStyle({
+ width: '80px',
+ height: '80px',
+ });
+
+ unmount();
+ });
+
+ it('should switch to red when recording the video', () => {
+ const { rerender, unmount } = render();
+
+ let buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl.children.item(0)).not.toHaveStyle({ backgroundColor: '#cb0000' });
+ rerender();
+ buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl.children.item(0)).toHaveStyle({ backgroundColor: '#cb0000' });
+
+ unmount();
+ });
+
+ it('should display the given tooltip', () => {
+ const tooltip = 'test-tooltip test';
+ const { unmount } = render();
+
+ expect(screen.queryByText(tooltip)).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should have a cursor pointer', () => {
+ const { unmount } = render();
+ const buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl).toHaveStyle({ cursor: 'pointer' });
+ unmount();
+ });
+
+ it('should pass down the disabled prop', () => {
+ const { unmount, rerender } = render();
+ let buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl).not.toHaveAttribute('disabled');
+ rerender();
+ buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl).toHaveAttribute('disabled');
+ unmount();
+ });
+
+ it('should pass the disabled prop to the useInteractiveStatus hook', () => {
+ const { unmount, rerender } = render();
+ const useInteractiveStatusMock = useInteractiveStatus as jest.Mock;
+ expect(useInteractiveStatusMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ disabled: false,
+ }),
+ );
+ useInteractiveStatusMock.mockClear();
+ rerender();
+ expect(useInteractiveStatusMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ disabled: true,
+ }),
+ );
+ unmount();
+ });
+
+ it('should have the proper style when disabled', () => {
+ const useInteractiveStatusMock = useInteractiveStatus as jest.Mock;
+ const disabledStyle = { cursor: 'default', opacity: 0.75 };
+ useInteractiveStatusMock.mockImplementationOnce(() => ({
+ status: InteractiveStatus.DEFAULT,
+ eventHandlers: {},
+ }));
+ const { unmount, rerender } = render();
+ let buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl).not.toHaveStyle(disabledStyle);
+
+ useInteractiveStatusMock.mockImplementationOnce(() => ({
+ status: InteractiveStatus.DISABLED,
+ eventHandlers: {},
+ }));
+ rerender();
+ buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ expect(buttonEl).toHaveStyle(disabledStyle);
+ unmount();
+ });
+
+ it('should pass the component handlers to the useInteractiveStatus hook and then to the button', () => {
+ const onMouseUp = jest.fn();
+ const onMouseEnter = jest.fn();
+ const onMouseLeave = jest.fn();
+ const onMouseDown = jest.fn();
+ const { unmount } = render(
+ ,
+ );
+ expect(useInteractiveStatus).toHaveBeenCalledWith(
+ expect.objectContaining({
+ componentHandlers: {
+ onMouseUp,
+ onMouseEnter,
+ onMouseLeave,
+ onMouseDown: expect.any(Function),
+ },
+ }),
+ );
+ const buttonEl = screen.getByTestId(RECORD_VIDEO_BUTTON_TEST_ID);
+ fireEvent.mouseEnter(buttonEl);
+ fireEvent.mouseDown(buttonEl);
+ fireEvent.mouseUp(buttonEl);
+ fireEvent.mouseLeave(buttonEl);
+ expect(onMouseUp).toHaveBeenCalled();
+ expect(onMouseEnter).toHaveBeenCalled();
+ expect(onMouseLeave).toHaveBeenCalled();
+ expect(onMouseDown).toHaveBeenCalled();
+ unmount();
+ });
+
+ it('should pass down the class name to the button', () => {
+ expectComponentToPassDownClassNameToHTMLElement(RecordVideoButton, RECORD_VIDEO_BUTTON_TEST_ID);
+ });
+
+ it('should pass down the ref to the button', () => {
+ expectComponentToPassDownRefToHTMLElement(RecordVideoButton, RECORD_VIDEO_BUTTON_TEST_ID);
+ });
+
+ it('should pass down the style to the button', () => {
+ expectComponentToPassDownStyleToHTMLElement(RecordVideoButton, RECORD_VIDEO_BUTTON_TEST_ID);
+ });
+
+ it('should pass down other props to the button', () => {
+ expectComponentToPassDownOtherPropsToHTMLElement(
+ RecordVideoButton,
+ RECORD_VIDEO_BUTTON_TEST_ID,
+ );
+ });
+});
diff --git a/packages/common-ui-web/test/components/VehicleWalkaroundIndicator.test.tsx b/packages/common-ui-web/test/components/VehicleWalkaroundIndicator.test.tsx
new file mode 100644
index 000000000..f5a2e1a45
--- /dev/null
+++ b/packages/common-ui-web/test/components/VehicleWalkaroundIndicator.test.tsx
@@ -0,0 +1,61 @@
+import '@testing-library/jest-dom';
+import { render, screen } from '@testing-library/react';
+import { VehicleWalkaroundIndicator } from '../../src';
+
+const PROGRESS_BAR_TEST_ID = 'progress-bar';
+const KNOB_TEST_ID = 'knob';
+
+describe('VehicleWalkaroundIndicator component', () => {
+ it('should set the size of the SVG of the value of the size prop', () => {
+ const size = 988;
+ const { container, unmount } = render();
+
+ expect(container.children.length).toEqual(1);
+ const svgEl = container.children.item(0) as SVGSVGElement;
+ expect(svgEl).toBeDefined();
+ expect(svgEl.tagName).toEqual('svg');
+ expect(svgEl).toHaveAttribute('width', size.toString());
+ expect(svgEl).toHaveAttribute('height', size.toString());
+ expect(svgEl).toHaveAttribute('viewBox', `0 0 ${size} ${size}`);
+
+ unmount();
+ });
+
+ it('should use 60 for the default size', () => {
+ const defaultSize = '60';
+ const { container, unmount } = render();
+
+ const svgEl = container.children.item(0) as SVGSVGElement;
+ expect(svgEl).toHaveAttribute('width', defaultSize);
+ expect(svgEl).toHaveAttribute('height', defaultSize);
+ expect(svgEl).toHaveAttribute('viewBox', `0 0 ${defaultSize} ${defaultSize}`);
+
+ unmount();
+ });
+
+ it('should display 8 circle steps, a progress bar and a knob', () => {
+ const { container, unmount } = render();
+
+ const svgEl = container.children.item(0) as SVGSVGElement;
+ expect(svgEl.children.length).toEqual(10);
+ expect(screen.queryByTestId(PROGRESS_BAR_TEST_ID)).not.toBeNull();
+ expect(screen.queryByTestId(KNOB_TEST_ID)).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should update the position of the knob when the alpha value changes', () => {
+ const { rerender, unmount } = render();
+
+ let knobEl = screen.getByTestId(KNOB_TEST_ID);
+ const cx = knobEl.getAttribute('cx');
+ const cy = knobEl.getAttribute('cy');
+
+ rerender();
+ knobEl = screen.getByTestId(KNOB_TEST_ID);
+ expect(knobEl.getAttribute('cx')).not.toEqual(cx);
+ expect(knobEl.getAttribute('cy')).not.toEqual(cy);
+
+ unmount();
+ });
+});
diff --git a/packages/common-ui-web/test/icons/Icon.test.tsx b/packages/common-ui-web/test/icons/Icon.test.tsx
index 1e6f0a987..e1a960941 100644
--- a/packages/common-ui-web/test/icons/Icon.test.tsx
+++ b/packages/common-ui-web/test/icons/Icon.test.tsx
@@ -1,3 +1,5 @@
+import { fullyColorSVG } from '@monkvision/common';
+
jest.mock('../../src/components/DynamicSVG', () => ({
DynamicSVG: jest.fn(() => <>>),
}));
@@ -12,12 +14,6 @@ import { expectPropsOnChildMock } from '@monkvision/test-utils';
import { MonkIconAssetsMap } from '../../src/icons/assets';
import { DynamicSVG, Icon } from '../../src';
-function createElement(attributes: Record): Element {
- return {
- getAttribute: (name: string) => attributes[name],
- } as unknown as Element;
-}
-
describe('Icon component', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -61,37 +57,15 @@ describe('Icon component', () => {
it('should properly replace color attributes with the primary color in the DynamicSVG component', () => {
const primaryColor = '#987654';
-
+ const attributes = { test: 'attr' };
+ (fullyColorSVG as jest.Mock).mockImplementationOnce(() => attributes);
const { unmount } = render();
expectPropsOnChildMock(DynamicSVG, { getAttributes: expect.any(Function) });
const { getAttributes } = (DynamicSVG as unknown as jest.Mock).mock.calls[0][0];
-
- const testCases = [
- { inputAttr: {}, outputAttr: {} },
- { inputAttr: { fill: '#999999' }, outputAttr: { fill: primaryColor } },
- { inputAttr: { stroke: '#123456' }, outputAttr: { stroke: primaryColor } },
- {
- inputAttr: { fill: '#192834', stroke: '#123456', path: 'test' },
- outputAttr: { fill: primaryColor, stroke: primaryColor },
- },
- { inputAttr: { stroke: 'none' }, outputAttr: {} },
- { inputAttr: { fill: 'transparent' }, outputAttr: {} },
- ];
- testCases.forEach(({ inputAttr, outputAttr }) => {
- const element = createElement(inputAttr);
- expect(getAttributes(element)).toEqual(outputAttr);
- });
-
- unmount();
- });
-
- it('should use the black color by default', () => {
- const { unmount } = render();
-
- expectPropsOnChildMock(DynamicSVG, { getAttributes: expect.any(Function) });
- const { getAttributes } = (DynamicSVG as unknown as jest.Mock).mock.calls[0][0];
- expect(getAttributes(createElement({ fill: '#121212' }))).toEqual({ fill: '#000000' });
+ const element = { getAttribute: jest.fn() };
+ expect(getAttributes(element)).toEqual(attributes);
+ expect(fullyColorSVG).toHaveBeenCalledWith(element, primaryColor);
unmount();
});
diff --git a/packages/common/README/APP_UTILS.md b/packages/common/README/APP_UTILS.md
index cf14a0b4c..d221709d2 100644
--- a/packages/common/README/APP_UTILS.md
+++ b/packages/common/README/APP_UTILS.md
@@ -37,11 +37,11 @@ parameters with values that can be fetched from the URL search parameters or the
- If `fetchFromSearchParams` is also set to `true`, the token fetched from the search params will always be
used in priority over the one fetched from the local storage.
-| Prop | Type | Description | Required | Default Value |
-|------------------|------------------|-------------------------------------------------------------------------------------------------------------------------------------|----------|---------------|
-| config | CaptureAppConfig | The current configuration of the application. | ✔️ | |
-| onFetchAuthToken | () => void | Callback called when an authentication token has successfully been fetched from either the local storage, or the URL search params. | | |
-| onFetchLanguage | () => void | Callback called when the language of the app must be updated because it has been specified in the URL params. | | |
+| Prop | Type | Description | Required | Default Value |
+|------------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------|----------|---------------|
+| config | PhotoCaptureAppConfig | The current configuration of the application. | ✔️ | |
+| onFetchAuthToken | () => void | Callback called when an authentication token has successfully been fetched from either the local storage, or the URL search params. | | |
+| onFetchLanguage | () => void | Callback called when the language of the app must be updated because it has been specified in the URL params. | | |
## useMonkAppState hook
This hook simply returns the current value of the `MonkAppStateContext` declared by the `MonkAppStateProvider`component.
diff --git a/packages/common/README/HOOKS.md b/packages/common/README/HOOKS.md
index 77399f97f..02d315f6c 100644
--- a/packages/common/README/HOOKS.md
+++ b/packages/common/README/HOOKS.md
@@ -58,6 +58,17 @@ This custom hook creates an interval that calls the provided async callback ever
call isn't still running. If `delay` is `null` or less than 0, the callback will not be called. The promise handlers
provided will only be called while the component is still mounted.
+### useDeviceOrientation
+```tsx
+import { useDeviceOrientation } from '@monkvision/common';
+
+function TestComponent() {
+ const { alpha } = useDeviceOrientation();
+ return Current compass angle : { alpha }
;
+}
+```
+This custom hook is used to get the device orientation data using the embedded compass on the device.
+
### useInteractiveStatus
```tsx
import { useInteractiveStatus } from '@monkvision/common';
diff --git a/packages/common/README/UTILITIES.md b/packages/common/README/UTILITIES.md
index 3d0effa52..bb0488c2a 100644
--- a/packages/common/README/UTILITIES.md
+++ b/packages/common/README/UTILITIES.md
@@ -109,6 +109,22 @@ const variants = getInteractiveVariants('#FC72A7');
Create interactive variants (hovered, active...) for the given color. You can specify as an additional parameter the
type of variation to use for the interactive colors (lighten or darken the color, default = lighten).
+### fullyColorSVG
+```tsx
+import { useCallback } from 'react';
+import { fullyColorSVG } from '@monkvision/common';
+import { DynamicSVG } from '@monkvision/common-ui-web';
+
+function TestComponent() {
+ const getAttributes = useCallback((element: Element) => fullyColorSVG(element, '#FFFFFF'), []);
+ return (
+
+ );
+}
+```
+This utility function can be passed to the `DynamicSVG` component's `getAttributes` prop to completely color an SVG
+with the given color. This is useful when wanting to color a single-color icon or logo.
+
---
# Config Utils
@@ -119,7 +135,7 @@ import { getAvailableVehicleTypes } from '@monkvision/common';
console.log(getAvailableVehicleTypes(config));
// Output : [VehicleType.CITY, VehicleType.SUV]
```
-Returns the list of available vehicle types based on the `sights` property of a `CaptureAppConfig` object.
+Returns the list of available vehicle types based on the `sights` property of a `PhotoCaptureAppConfig` object.
# Environment Utils
### getEnvOrThrow
diff --git a/packages/common/src/apps/appState.ts b/packages/common/src/apps/appState.ts
index 634eb1e7d..1cfce3875 100644
--- a/packages/common/src/apps/appState.ts
+++ b/packages/common/src/apps/appState.ts
@@ -1,21 +1,22 @@
-import { CaptureAppConfig, Sight, SteeringWheelPosition, VehicleType } from '@monkvision/types';
+import {
+ PhotoCaptureAppConfig,
+ Sight,
+ SteeringWheelPosition,
+ VehicleType,
+ VideoCaptureAppConfig,
+} from '@monkvision/types';
import { createContext } from 'react';
import { LoadingState } from '../hooks';
/**
- * Application state usually used by Monk applications to configure and handle the current user journey.
+ * Shared app states values by both photo and video capture workflows.
*/
-export interface MonkAppState {
+export interface SharedMonkAppState {
/**
* LoadingState indicating if the application state is loading. If it is loading it usually means that the provider
* did not have time to fetch the parameter values.
*/
loading: LoadingState;
- /**
- * The current configuration of the application.
- */
- config: CaptureAppConfig;
-
/**
* The authentication token representing the currently logged-in user. If this param is `null`, it means the user is
* not logged in.
@@ -26,6 +27,24 @@ export interface MonkAppState {
* param is `null`, it probably means that the inspection must be created by the app.
*/
inspectionId: string | null;
+ /**
+ * Setter function used to set the current auth token.
+ */
+ setAuthToken: (value: string | null) => void;
+ /**
+ * Setter function used to set the current inspection ID.
+ */
+ setInspectionId: (value: string | null) => void;
+}
+
+/**
+ * App state values available in PhotoCapture applications.
+ */
+export interface PhotoCaptureAppState extends SharedMonkAppState {
+ /**
+ * The current configuration of the application.
+ */
+ config: PhotoCaptureAppConfig;
/**
* The current vehicle type of the app. This value usually helps to choose which sights to display to the user, or
* which car 360 wireframes to use for the inspection report.
@@ -43,15 +62,6 @@ export interface MonkAppState {
* Getter function used to get the current Sights based on the current VehicleType, SteeringWheel position etc.
*/
getCurrentSights: () => Sight[];
-
- /**
- * Setter function used to set the current auth token.
- */
- setAuthToken: (value: string | null) => void;
- /**
- * Setter function used to set the current inspection ID.
- */
- setInspectionId: (value: string | null) => void;
/**
* Setter function used to set the current vehicle type.
*/
@@ -62,6 +72,21 @@ export interface MonkAppState {
setSteeringWheel: (value: SteeringWheelPosition | null) => void;
}
+/**
+ * App state values available in PhotoCapture applications.
+ */
+export interface VideoCaptureAppState extends SharedMonkAppState {
+ /**
+ * The current configuration of the application.
+ */
+ config: VideoCaptureAppConfig;
+}
+
+/**
+ * Application state usually used by Monk applications to configure and handle the current user journey.
+ */
+export type MonkAppState = PhotoCaptureAppState | VideoCaptureAppState;
+
/**
* React context used to store the current Monk application state.
*
diff --git a/packages/common/src/apps/appStateProvider.tsx b/packages/common/src/apps/appStateProvider.tsx
index 09106aea3..b509b2bc8 100644
--- a/packages/common/src/apps/appStateProvider.tsx
+++ b/packages/common/src/apps/appStateProvider.tsx
@@ -1,16 +1,21 @@
-import { CaptureAppConfig, Sight, SteeringWheelPosition, VehicleType } from '@monkvision/types';
+import {
+ CaptureWorkflow,
+ PhotoCaptureAppConfig,
+ Sight,
+ SteeringWheelPosition,
+ VehicleType,
+ VideoCaptureAppConfig,
+} from '@monkvision/types';
import { sights } from '@monkvision/sights';
-import React, {
- PropsWithChildren,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useState,
-} from 'react';
-import { useLoadingState, useObjectMemo, useIsMounted } from '../hooks';
+import React, { PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react';
+import { useIsMounted, useLoadingState } from '../hooks';
import { MonkSearchParam, useMonkSearchParams } from './searchParams';
-import { MonkAppState, MonkAppStateContext } from './appState';
+import {
+ MonkAppState,
+ MonkAppStateContext,
+ PhotoCaptureAppState,
+ VideoCaptureAppState,
+} from './appState';
import { useAppStateMonitoring } from './monitoring';
import { useAppStateAnalytics } from './analytics';
import { getAvailableVehicleTypes } from '../utils';
@@ -27,7 +32,7 @@ export type MonkAppStateProviderProps = {
/**
* The current configuration of the application.
*/
- config: CaptureAppConfig;
+ config: PhotoCaptureAppConfig | VideoCaptureAppConfig;
/**
* Callback called when an authentication token has successfully been fetched from either the local storage, or the
* URL search params.
@@ -40,7 +45,7 @@ export type MonkAppStateProviderProps = {
};
function getSights(
- config: CaptureAppConfig,
+ config: PhotoCaptureAppConfig,
vehicleType: VehicleType | null,
steeringWheel: SteeringWheelPosition | null,
): Sight[] {
@@ -85,7 +90,11 @@ export function MonkAppStateProvider({
const [inspectionId, setInspectionId] = useState(null);
const [vehicleType, setVehicleType] = useState(null);
const [steeringWheel, setSteeringWheel] = useState(null);
- const availableVehicleTypes = useMemo(() => getAvailableVehicleTypes(config), [config]);
+ const availableVehicleTypes = useMemo(
+ () =>
+ config.workflow === CaptureWorkflow.PHOTO ? getAvailableVehicleTypes(config) : undefined,
+ [config],
+ );
const monkSearchParams = useMonkSearchParams({ availableVehicleTypes });
const isMounted = useIsMounted();
useAppStateMonitoring({ authToken, inspectionId, vehicleType, steeringWheel });
@@ -112,25 +121,54 @@ export function MonkAppStateProvider({
}
}, [monkSearchParams, config]);
- const getCurrentSights = useCallback(
- () => getSights(config, vehicleType, steeringWheel),
+ const getCurrentSights = useMemo(
+ () =>
+ config.workflow === CaptureWorkflow.PHOTO
+ ? () => getSights(config, vehicleType, steeringWheel)
+ : undefined,
[config, vehicleType, steeringWheel],
);
- const appState = useObjectMemo({
- loading,
- config,
- authToken,
- inspectionId,
- vehicleType,
- availableVehicleTypes,
- steeringWheel,
- getCurrentSights,
- setAuthToken,
- setInspectionId,
- setVehicleType,
- setSteeringWheel,
- });
+ const appState: MonkAppState = useMemo(
+ () =>
+ config.workflow === CaptureWorkflow.VIDEO
+ ? {
+ loading,
+ config,
+ authToken,
+ inspectionId,
+ setAuthToken,
+ setInspectionId,
+ }
+ : {
+ loading,
+ config,
+ authToken,
+ inspectionId,
+ vehicleType,
+ availableVehicleTypes: availableVehicleTypes as VehicleType[],
+ steeringWheel,
+ getCurrentSights: getCurrentSights as () => Sight[],
+ setAuthToken,
+ setInspectionId,
+ setVehicleType,
+ setSteeringWheel,
+ },
+ [
+ loading,
+ config,
+ authToken,
+ inspectionId,
+ vehicleType,
+ availableVehicleTypes,
+ steeringWheel,
+ getCurrentSights,
+ setAuthToken,
+ setInspectionId,
+ setVehicleType,
+ setSteeringWheel,
+ ],
+ );
return {children};
}
@@ -145,6 +183,26 @@ export interface UseMonkAppStateOptions {
* params, at the cost of throwing an error if either one of these param is `null`.
*/
requireInspection?: boolean;
+ /**
+ * The required capture workflow. If this option is passed, the hook will return a MonkState value already type
+ * checked and cast into the proper capture workflo, at the cost of throwing an error if the required worfklow does
+ * not match the one in the current state config.
+ */
+ requireWorkflow?: CaptureWorkflow;
+}
+
+/**
+ * Custom type used when using the `requireInspection` option with the `useMonkAppState` hook.
+ */
+export interface RequiredInspectionAppState {
+ /**
+ * The authentication token representing the currently logged-in user.
+ */
+ authToken: string;
+ /**
+ * The ID of the current inspection being handled (picture taking, report viewing...) by the application.
+ */
+ inspectionId: string;
}
/**
@@ -156,10 +214,42 @@ export interface UseMonkAppStateOptions {
* @see MonkAppStateProvider
*/
export function useMonkAppState(): MonkAppState;
+export function useMonkAppState(o: Record): MonkAppState;
export function useMonkAppState(o: { requireInspection: false | undefined }): MonkAppState;
export function useMonkAppState(o: {
requireInspection: true;
-}): MonkAppState & { authToken: string; inspectionId: string };
+}): MonkAppState & RequiredInspectionAppState;
+export function useMonkAppState(o: { requireWorkflow: undefined }): MonkAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: undefined;
+ requireInspection: false | undefined;
+}): MonkAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: undefined;
+ requireInspection: true;
+}): MonkAppState & RequiredInspectionAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: CaptureWorkflow.PHOTO;
+}): PhotoCaptureAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: CaptureWorkflow.PHOTO;
+ requireInspection: false | undefined;
+}): PhotoCaptureAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: CaptureWorkflow.PHOTO;
+ requireInspection: true;
+}): PhotoCaptureAppState & RequiredInspectionAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: CaptureWorkflow.VIDEO;
+}): VideoCaptureAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: CaptureWorkflow.VIDEO;
+ requireInspection: false | undefined;
+}): VideoCaptureAppState;
+export function useMonkAppState(o: {
+ requireWorkflow: CaptureWorkflow.VIDEO;
+ requireInspection: true;
+}): VideoCaptureAppState & RequiredInspectionAppState;
export function useMonkAppState(options?: UseMonkAppStateOptions): MonkAppState {
const value = useContext(MonkAppStateContext);
if (!value) {
@@ -173,5 +263,10 @@ export function useMonkAppState(options?: UseMonkAppStateOptions): MonkAppState
if (options?.requireInspection && !value.inspectionId) {
throw new Error('Inspection ID is null but was required by the current component.');
}
+ if (options?.requireWorkflow && value.config.workflow !== options?.requireWorkflow) {
+ throw new Error(
+ 'The capture workflow is different than the one required by the current component.',
+ );
+ }
return value;
}
diff --git a/packages/common/src/apps/monitoring.ts b/packages/common/src/apps/monitoring.ts
index 6ccff1891..40db1c84b 100644
--- a/packages/common/src/apps/monitoring.ts
+++ b/packages/common/src/apps/monitoring.ts
@@ -1,14 +1,17 @@
import { useEffect } from 'react';
import { jwtDecode } from 'jwt-decode';
import { useMonitoring } from '@monkvision/monitoring';
-import { MonkAppState } from './appState';
+import { MonkAppState, PhotoCaptureAppState } from './appState';
export function useAppStateMonitoring({
authToken,
inspectionId,
vehicleType,
steeringWheel,
-}: Pick): void {
+}: Partial<
+ Pick &
+ Pick
+>): void {
const { setTags, setUserId } = useMonitoring();
useEffect(() => {
diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts
index e9703ab27..65b1ebbd3 100644
--- a/packages/common/src/hooks/index.ts
+++ b/packages/common/src/hooks/index.ts
@@ -12,3 +12,4 @@ export * from './useAsyncInterval';
export * from './useObjectMemo';
export * from './useForm';
export * from './useIsMounted';
+export * from './useDeviceOrientation';
diff --git a/packages/common/src/hooks/useDeviceOrientation.ts b/packages/common/src/hooks/useDeviceOrientation.ts
new file mode 100644
index 000000000..8697ffce3
--- /dev/null
+++ b/packages/common/src/hooks/useDeviceOrientation.ts
@@ -0,0 +1,107 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useObjectMemo } from './useObjectMemo';
+
+enum DeviceOrientationPermissionResponse {
+ GRANTED = 'granted',
+ DENIED = 'denied',
+}
+
+interface DeviceOrientationEventiOS extends DeviceOrientationEvent {
+ webkitCompassHeading?: number;
+ requestPermission?: () => Promise;
+}
+
+/**
+ * Options accepted by the useDeviceOrientation hook.
+ */
+export interface UseDeviceOrientationOptions {
+ /**
+ * Custom event handler that will be called every time a device orientation event is fired by the device.
+ */
+ onDeviceOrientationEvent?: (event: DeviceOrientationEvent) => void;
+}
+
+/**
+ * Handle used to mcontrol the device orientation.
+ */
+export interface DeviceOrientationHandle {
+ /**
+ * Boolean indicating if the permission for the device's compass data has been granted. It is equal to `false` by
+ * default, and will be equal to true once the `requestCompassPermission` method has successfuly resolved.
+ */
+ isPermissionGranted: boolean;
+ /**
+ * Async function used to ask for the compass permission on the device.
+ * - On iOS, a pop-up will appear asking for the user confirmation. This function will reject if something goes wrong
+ * or if the user declines.
+ * - On Android and other devices, this function will resolve directly and the process will be seemless for the user.
+ */
+ requestCompassPermission: () => Promise;
+ /**
+ * The current `alpha` value of the device. This value is a number in degrees (between 0 and 360), and represents the
+ * orientation of the device on the compass (AKA on the Z axis or "yaw", 0 = pointing North, 90 = pointing East etc.).
+ * This value starts being updated once the permissions for the compass has been granted using the
+ * `requestCompassPermission` method.
+ */
+ alpha: number;
+ /**
+ * A number representing the motion of the device around the X axis, expressed in degrees with values ranging from
+ * -180 (inclusive) to 180 (exclusive). This represents a front to back motion of the device AKA the "pitch".
+ */
+ beta: number;
+ /**
+ * A number representing the motion of the device around the Y axis, express in degrees with values ranging from -90
+ * (inclusive) to 90 (exclusive). This represents a left to right motion of the device AKA the "roll".
+ */
+ gamma: number;
+}
+
+/**
+ * Custom hook used to get the device orientation data using the embedded compass on the device.
+ */
+export function useDeviceOrientation(
+ options?: UseDeviceOrientationOptions,
+): DeviceOrientationHandle {
+ const [isPermissionGranted, setIsPermissionGranted] = useState(false);
+ const [alpha, setAlpha] = useState(0);
+ const [beta, setBeta] = useState(0);
+ const [gamma, setGamma] = useState(0);
+
+ const handleDeviceOrientationEvent = useCallback(
+ (event: DeviceOrientationEvent) => {
+ const value = (event as DeviceOrientationEventiOS).webkitCompassHeading ?? event.alpha ?? 0;
+ setAlpha(value);
+ setBeta(event.beta ?? 0);
+ setGamma(event.gamma ?? 0);
+ options?.onDeviceOrientationEvent?.(event);
+ },
+ [options?.onDeviceOrientationEvent],
+ );
+
+ const requestCompassPermission = useCallback(async () => {
+ if (DeviceOrientationEvent) {
+ const { requestPermission } = DeviceOrientationEvent as unknown as DeviceOrientationEventiOS;
+ if (typeof requestPermission === 'function') {
+ const response = await requestPermission();
+ if (response !== DeviceOrientationPermissionResponse.GRANTED) {
+ throw new Error('Device orientation permission request denied.');
+ }
+ }
+ }
+ setIsPermissionGranted(true);
+ }, []);
+
+ useEffect(() => {
+ if (isPermissionGranted) {
+ window.addEventListener('deviceorientation', handleDeviceOrientationEvent);
+ }
+
+ return () => {
+ if (isPermissionGranted) {
+ window.removeEventListener('deviceorientation', handleDeviceOrientationEvent);
+ }
+ };
+ }, [isPermissionGranted, handleDeviceOrientationEvent]);
+
+ return useObjectMemo({ alpha, beta, gamma, isPermissionGranted, requestCompassPermission });
+}
diff --git a/packages/common/src/utils/color.utils.ts b/packages/common/src/utils/color.utils.ts
index b47858ac8..a7a40f6de 100644
--- a/packages/common/src/utils/color.utils.ts
+++ b/packages/common/src/utils/color.utils.ts
@@ -1,4 +1,5 @@
import { InteractiveStatus, RGBA } from '@monkvision/types';
+import { SVGProps } from 'react';
const RGBA_REGEXP = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d+(?:\.\d+)?)\s*)?\)$/i;
const HEX_REGEXP = /^#(?:(?:[0-9a-f]{3}){1,2}|(?:[0-9a-f]{4}){1,2})$/i;
@@ -131,3 +132,30 @@ export function getInteractiveVariants(
[InteractiveStatus.DISABLED]: color,
};
}
+
+const COLOR_ATTRIBUTES = ['fill', 'stroke'];
+
+/**
+ * This utility function can be passed to the `DynamicSVG` component's `getAttributes` prop to completely color an SVG
+ * with the given color. This is useful when wanting to color a single-color icon or logo.
+ *
+ * @example
+ * function TestComponent() {
+ * const getAttributes = useCallback((element: Element) => fullyColorSVG(element, '#FFFFFF'), []);
+ * return (
+ *
+ * );
+ * }
+ */
+export function fullyColorSVG(element: Element, color: string): SVGProps {
+ return COLOR_ATTRIBUTES.reduce((customAttributes, colorAttribute) => {
+ const attr = element.getAttribute(colorAttribute);
+ if (attr && !['transparent', 'none'].includes(attr)) {
+ return {
+ ...customAttributes,
+ [colorAttribute]: color,
+ };
+ }
+ return customAttributes;
+ }, {});
+}
diff --git a/packages/common/src/utils/config.utils.ts b/packages/common/src/utils/config.utils.ts
index d982ccc6e..a1e0170d8 100644
--- a/packages/common/src/utils/config.utils.ts
+++ b/packages/common/src/utils/config.utils.ts
@@ -1,10 +1,10 @@
-import { CaptureAppConfig, VehicleType } from '@monkvision/types';
+import { PhotoCaptureAppConfig, VehicleType } from '@monkvision/types';
import { uniq } from './array.utils';
/**
* Util function used to extract the list of available vehicle types in a CaptureAppConfig object.
*/
-export function getAvailableVehicleTypes(config: CaptureAppConfig): VehicleType[] {
+export function getAvailableVehicleTypes(config: PhotoCaptureAppConfig): VehicleType[] {
return (
config.enableSteeringWheelPosition
? uniq([...Object.keys(config.sights.left), ...Object.keys(config.sights.right)])
diff --git a/packages/common/test/apps/appStateProvider.test.tsx b/packages/common/test/apps/appStateProvider.test.tsx
index c2fc50944..82af96661 100644
--- a/packages/common/test/apps/appStateProvider.test.tsx
+++ b/packages/common/test/apps/appStateProvider.test.tsx
@@ -10,7 +10,12 @@ jest.mock('../../src/utils', () => ({
}));
import React, { useContext, useEffect } from 'react';
-import { CaptureAppConfig, SteeringWheelPosition, VehicleType } from '@monkvision/types';
+import {
+ CaptureWorkflow,
+ PhotoCaptureAppConfig,
+ SteeringWheelPosition,
+ VehicleType,
+} from '@monkvision/types';
import { sights } from '@monkvision/sights';
import { renderHook } from '@testing-library/react-hooks';
import { act, render, screen } from '@testing-library/react';
@@ -20,16 +25,18 @@ import {
MonkAppStateProvider,
MonkAppStateProviderProps,
MonkSearchParam,
+ PhotoCaptureAppState,
STORAGE_KEY_AUTH_TOKEN,
useMonkAppState,
+ UseMonkAppStateOptions,
useMonkSearchParams,
} from '../../src';
-let params: MonkAppState | null = null;
+let params: PhotoCaptureAppState | null = null;
function TestComponent() {
const context = useContext(MonkAppStateContext);
useEffect(() => {
- params = context;
+ params = context as PhotoCaptureAppState;
});
return <>>;
}
@@ -38,9 +45,10 @@ function mockSearchParams(searchParams: Partial>):
searchParamsGet.mockImplementation((param) => searchParams[param as MonkSearchParam]);
}
-function createProps(): MonkAppStateProviderProps {
+function createProps(): MonkAppStateProviderProps & { config: PhotoCaptureAppConfig } {
return {
config: {
+ workflow: CaptureWorkflow.PHOTO,
fetchFromSearchParams: false,
enableSteeringWheelPosition: false,
defaultVehicleType: VehicleType.CUV,
@@ -48,12 +56,14 @@ function createProps(): MonkAppStateProviderProps {
[VehicleType.HATCHBACK]: ['test-sight-1', 'test-sight-2'],
[VehicleType.CUV]: ['test-sight-3', 'test-sight-4'],
},
- } as CaptureAppConfig,
+ } as PhotoCaptureAppConfig,
onFetchAuthToken: jest.fn(),
onFetchLanguage: jest.fn(),
};
}
+const useMonkAppStateTyped = useMonkAppState as (options?: UseMonkAppStateOptions) => MonkAppState;
+
describe('MonkAppStateProvider', () => {
afterEach(() => {
jest.clearAllMocks();
@@ -374,7 +384,7 @@ describe('MonkAppStateProvider', () => {
const value = { test: 'hello' };
const spy = jest.spyOn(React, 'useContext').mockImplementationOnce(() => value);
- const { result, unmount } = renderHook(useMonkAppState);
+ const { result, unmount } = renderHook(useMonkAppStateTyped);
expect(spy).toHaveBeenCalledWith(MonkAppStateContext);
expect(result.current).toEqual(value);
@@ -387,7 +397,7 @@ describe('MonkAppStateProvider', () => {
const value = { inspectionId: 'hello' };
jest.spyOn(React, 'useContext').mockImplementationOnce(() => value);
- const { result, unmount } = renderHook(useMonkAppState, {
+ const { result, unmount } = renderHook(useMonkAppStateTyped, {
initialProps: { requireInspection: true },
});
@@ -402,7 +412,7 @@ describe('MonkAppStateProvider', () => {
const value = { authToken: 'hello' };
jest.spyOn(React, 'useContext').mockImplementationOnce(() => value);
- const { result, unmount } = renderHook(useMonkAppState, {
+ const { result, unmount } = renderHook(useMonkAppStateTyped, {
initialProps: { requireInspection: true },
});
@@ -416,7 +426,7 @@ describe('MonkAppStateProvider', () => {
const value = { authToken: 'hello', inspectionId: 'hi' };
jest.spyOn(React, 'useContext').mockImplementationOnce(() => value);
- const { result, unmount } = renderHook(useMonkAppState, {
+ const { result, unmount } = renderHook(useMonkAppStateTyped, {
initialProps: { requireInspection: true },
});
@@ -425,5 +435,20 @@ describe('MonkAppStateProvider', () => {
unmount();
});
+
+ it('should throw an error if the required workflow is different than the one of the current state', () => {
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+ const value = { config: { workflow: CaptureWorkflow.PHOTO } };
+ jest.spyOn(React, 'useContext').mockImplementationOnce(() => value);
+
+ const { result, unmount } = renderHook(useMonkAppStateTyped, {
+ initialProps: { requireWorkflow: CaptureWorkflow.VIDEO },
+ });
+
+ expect(result.error).toBeDefined();
+
+ unmount();
+ jest.spyOn(console, 'error').mockRestore();
+ });
});
});
diff --git a/packages/common/test/hooks/useDeviceOrientation.test.ts b/packages/common/test/hooks/useDeviceOrientation.test.ts
new file mode 100644
index 000000000..487c63942
--- /dev/null
+++ b/packages/common/test/hooks/useDeviceOrientation.test.ts
@@ -0,0 +1,201 @@
+import { renderHook } from '@testing-library/react-hooks';
+import { act } from '@testing-library/react';
+import { useDeviceOrientation } from '../../src';
+
+function useDefaultDeviceOrientationEvent(): void {
+ Object.defineProperty(global, 'DeviceOrientationEvent', {
+ writable: true,
+ value: {},
+ });
+}
+
+function useiOSDeviceOrientationEvent(value: string): jest.Mock {
+ const requestPermission = jest.fn(() => Promise.resolve(value));
+ Object.defineProperty(global, 'DeviceOrientationEvent', {
+ writable: true,
+ value: { requestPermission },
+ });
+ return requestPermission;
+}
+
+describe('useDeviceOrientation hook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useDefaultDeviceOrientationEvent();
+ });
+
+ it('should not have permission granted at start', () => {
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(result.current.isPermissionGranted).toBe(false);
+
+ unmount();
+ });
+
+ it('should directly resolve when calling requestCompassPermission on other devices than iOS', async () => {
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(typeof result.current.requestCompassPermission).toBe('function');
+ await act(async () => {
+ await result.current.requestCompassPermission();
+ });
+ expect(result.current.isPermissionGranted).toBe(true);
+
+ unmount();
+ });
+
+ it('should make a call to requestPermission when calling requestCompassPermission on iOS', async () => {
+ const requestPermission = useiOSDeviceOrientationEvent('granted');
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(typeof result.current.requestCompassPermission).toBe('function');
+ await act(async () => {
+ await result.current.requestCompassPermission();
+ });
+ expect(requestPermission).toHaveBeenCalled();
+ expect(result.current.isPermissionGranted).toBe(true);
+
+ unmount();
+ });
+
+ it('should reject when calling requestCompassPermission on iOS when requestPermission fails', async () => {
+ const spy = jest.spyOn(window, 'addEventListener');
+ const requestPermission = useiOSDeviceOrientationEvent('denied');
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(spy).not.toHaveBeenCalledWith('deviceorientation', expect.anything());
+ expect(typeof result.current.requestCompassPermission).toBe('function');
+ await act(async () => {
+ await expect(() => result.current.requestCompassPermission()).rejects.toBeInstanceOf(Error);
+ });
+ expect(requestPermission).toHaveBeenCalled();
+ expect(result.current.isPermissionGranted).toBe(false);
+ expect(spy).not.toHaveBeenCalledWith('deviceorientation', expect.anything());
+
+ unmount();
+ });
+
+ it('should start with an alpha value of 0', () => {
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(result.current.alpha).toBe(0);
+
+ unmount();
+ });
+
+ it('should start with a beta value of 0', () => {
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(result.current.beta).toBe(0);
+
+ unmount();
+ });
+
+ it('should start with a gamma value of 0', () => {
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(result.current.gamma).toBe(0);
+
+ unmount();
+ });
+
+ it('should update the alpha value with webkitCompassHeading when available', async () => {
+ const spy = jest.spyOn(window, 'addEventListener');
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(spy).not.toHaveBeenCalledWith('deviceorientation', expect.anything());
+ await act(async () => {
+ await result.current.requestCompassPermission();
+ });
+ expect(spy).toHaveBeenCalledWith('deviceorientation', expect.any(Function));
+ const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as (
+ event: any,
+ ) => void;
+ expect(result.current.alpha).toBe(0);
+
+ const value = 42;
+ act(() => eventHandler({ webkitCompassHeading: value, alpha: 2222 }));
+ expect(result.current.alpha).toEqual(value);
+
+ unmount();
+ });
+
+ it('should update the alpha value with alpha if webkitCompassHeading is not available', async () => {
+ const spy = jest.spyOn(window, 'addEventListener');
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ expect(spy).not.toHaveBeenCalledWith('deviceorientation', expect.anything());
+ await act(async () => {
+ await result.current.requestCompassPermission();
+ });
+ expect(spy).toHaveBeenCalledWith('deviceorientation', expect.any(Function));
+ const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as (
+ event: any,
+ ) => void;
+ expect(result.current.alpha).toBe(0);
+
+ const value = 2223;
+ act(() => eventHandler({ alpha: value }));
+ expect(result.current.alpha).toEqual(value);
+
+ unmount();
+ });
+
+ it('should return the beta value of the device orientation event', async () => {
+ const spy = jest.spyOn(window, 'addEventListener');
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ await act(async () => {
+ await result.current.requestCompassPermission();
+ });
+ const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as (
+ event: any,
+ ) => void;
+
+ const value = 12;
+ act(() => eventHandler({ beta: value }));
+ expect(result.current.beta).toEqual(value);
+
+ unmount();
+ });
+
+ it('should return the gamma value of the device orientation event', async () => {
+ const spy = jest.spyOn(window, 'addEventListener');
+ const { result, unmount } = renderHook(useDeviceOrientation);
+
+ await act(async () => {
+ await result.current.requestCompassPermission();
+ });
+ const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as (
+ event: any,
+ ) => void;
+
+ const value = 34;
+ act(() => eventHandler({ gamma: value }));
+ expect(result.current.gamma).toEqual(value);
+
+ unmount();
+ });
+
+ it('should call the custom event handler if passed in the hook options', async () => {
+ const spy = jest.spyOn(window, 'addEventListener');
+ const onDeviceOrientationEvent = jest.fn();
+ const { result, unmount } = renderHook(useDeviceOrientation, {
+ initialProps: { onDeviceOrientationEvent },
+ });
+
+ await act(async () => {
+ await result.current.requestCompassPermission();
+ });
+ const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as (
+ event: any,
+ ) => void;
+
+ const testEvent = { test: 'heloo' };
+ expect(onDeviceOrientationEvent).not.toHaveBeenCalled();
+ act(() => eventHandler(testEvent));
+ expect(onDeviceOrientationEvent).toHaveBeenCalledWith(testEvent);
+
+ unmount();
+ });
+});
diff --git a/packages/common/test/utils/color.utils.test.ts b/packages/common/test/utils/color.utils.test.ts
index e63eabb8b..2b46a7812 100644
--- a/packages/common/test/utils/color.utils.test.ts
+++ b/packages/common/test/utils/color.utils.test.ts
@@ -1,6 +1,7 @@
import { InteractiveStatus } from '@monkvision/types';
import {
changeAlpha,
+ fullyColorSVG,
getHexFromRGBA,
getInteractiveVariants,
getRGBAFromString,
@@ -129,4 +130,42 @@ describe('Color utils', () => {
);
});
});
+
+ describe('fullyColorSVG function', () => {
+ const color = '#A3B68C';
+ [
+ {
+ name: 'should replace the color attributes of the element with the given color',
+ attributes: { fill: '#1234356', stroke: '#654321', width: '220' },
+ expected: { fill: color, stroke: color },
+ },
+ {
+ name: 'should not add new color attributes',
+ attributes: { height: '220' },
+ expected: {},
+ },
+ {
+ name: 'should ignore transparent color attributes',
+ attributes: { fill: 'transparent', stroke: '#FF6600' },
+ expected: { stroke: color },
+ },
+ {
+ name: 'should ignore none color attributes',
+ attributes: { fill: 'none', stroke: 'none' },
+ expected: {},
+ },
+ ].forEach(({ name, attributes, expected }) => {
+ // eslint-disable-next-line jest/valid-title
+ it(name, () => {
+ const element = {
+ getAttribute: jest.fn(
+ (attr: string) => (attributes as Record)[attr] ?? null,
+ ),
+ } as unknown as Element;
+ const actual = fullyColorSVG(element, color);
+ expect(actual).toEqual(expect.objectContaining(expected));
+ expect(expected).toEqual(expect.objectContaining(actual));
+ });
+ });
+ });
});
diff --git a/packages/common/test/utils/config.utils.test.ts b/packages/common/test/utils/config.utils.test.ts
index 5845817ff..8d78e45fb 100644
--- a/packages/common/test/utils/config.utils.test.ts
+++ b/packages/common/test/utils/config.utils.test.ts
@@ -1,4 +1,4 @@
-import { CaptureAppConfig, SteeringWheelPosition, VehicleType } from '@monkvision/types';
+import { PhotoCaptureAppConfig, SteeringWheelPosition, VehicleType } from '@monkvision/types';
import { getAvailableVehicleTypes } from '../../src';
describe('Config utils', () => {
@@ -7,7 +7,7 @@ describe('Config utils', () => {
const config = {
enableSteeringWheelPosition: false,
sights: { [VehicleType.SEDAN]: [], [VehicleType.HGV]: [] },
- } as unknown as CaptureAppConfig;
+ } as unknown as PhotoCaptureAppConfig;
expect(getAvailableVehicleTypes(config)).toEqual([VehicleType.SEDAN, VehicleType.HGV]);
});
@@ -18,7 +18,7 @@ describe('Config utils', () => {
[SteeringWheelPosition.LEFT]: { [VehicleType.VAN]: [], [VehicleType.CITY]: [] },
[SteeringWheelPosition.RIGHT]: { [VehicleType.VAN]: [], [VehicleType.CITY]: [] },
},
- } as unknown as CaptureAppConfig;
+ } as unknown as PhotoCaptureAppConfig;
expect(getAvailableVehicleTypes(config)).toEqual([VehicleType.VAN, VehicleType.CITY]);
});
@@ -29,7 +29,7 @@ describe('Config utils', () => {
[SteeringWheelPosition.LEFT]: { [VehicleType.VAN]: [], [VehicleType.LARGE_SUV]: [] },
[SteeringWheelPosition.RIGHT]: { [VehicleType.VAN]: [], [VehicleType.HATCHBACK]: [] },
},
- } as unknown as CaptureAppConfig;
+ } as unknown as PhotoCaptureAppConfig;
expect(getAvailableVehicleTypes(config)).toEqual([
VehicleType.VAN,
VehicleType.LARGE_SUV,
diff --git a/packages/inspection-capture-web/README.md b/packages/inspection-capture-web/README.md
index e6098a20b..653223654 100644
--- a/packages/inspection-capture-web/README.md
+++ b/packages/inspection-capture-web/README.md
@@ -17,7 +17,7 @@ If you are using TypeScript, this package comes with its type definitions integr
anything else!
# PhotoCapture
-The PhotoCapture wofklow is aimed at guiding users in taking pictures of their vehicle in order to add them to a Monk
+The PhotoCapture workflow is aimed at guiding users in taking pictures of their vehicle in order to add them to a Monk
inspection. The user is shown a set of car wireframes, which we call *Sights* and that are available in the
`@monkvision/sights` package. These Sights act as guides, and the user is asked to take pictures of their vehicle by
aligning it with the Sights.
@@ -62,25 +62,27 @@ export function MonkPhotoCapturePage({ authToken }) {
| Prop | Type | Description | Required | Default Value |
|------------------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------|
-| sights | Sight[] | The list of Sights to take pictures of. The values in this array should be retreived from the `@monkvision/sights` package. | ✔️ | |
+| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` |
+| quality | `number` | Value indicating image quality for the compression output. | | `0.6` |
+| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` |
+| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` |
+| additionalTasks | `TaskName[]` | An optional list of additional tasks to run on every image of the inspection. | | |
+| startTasksOnComplete | `boolean | TaskName[]` | Value indicating if tasks should be started at the end of the inspection. See the `inspection-capture-web` package doc for more info. | | `true` |
+| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | |
| inspectionId | string | The ID of the inspection to add images to. Make sure that the user that created the inspection if the same one as the one described in the auth token in the `apiConfig` prop. | ✔️ | |
| apiConfig | ApiConfig | The api config used to communicate with the API. Make sure that the user described in the auth token is the same one as the one that created the inspection provided in the `inspectionId` prop. | ✔️ | |
-| onClose | `() => void` | Callback called when the user clicks on the Close button. If this callback is not provided, the button will not be displayed on the screen. | | |
| onComplete | `() => void` | Callback called when inspection capture is complete. | | |
-| onPictureTaken | `(picture: MonkPicture) => void` | Callback called when the user has taken a picture in the Capture process. | | |
| lang | string | null | The language to be used by this component. | | `'en'` |
-| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | |
-| maxUploadDurationWarning | `number` | Max upload duration in milliseconds before showing a bad connection warning to the user. Use `-1` to never display the warning. | | `15000` |
-| useAdaptiveImageQuality | `boolean` | Boolean indicating if the image quality should be downgraded automatically in case of low connection. | | `true` |
-| showCloseButton | `boolean` | Indicates if the close button should be displayed in the HUD on top of the Camera preview. | | `false` |
-| startTasksOnComplete | `boolean | TaskName[]` | Value indicating if tasks should be started at the end of the inspection. See the `inspection-capture-web` package doc for more info. | | `true` |
-| additionalTasks | `TaskName[]` | An optional list of additional tasks to run on every Sight of the inspection. | | |
+| sights | Sight[] | The list of Sights to take pictures of. The values in this array should be retreived from the `@monkvision/sights` package. | ✔️ | |
| tasksBySight | `Record` | Record associating each sight with a list of tasks to execute for it. If not provided, the default tasks of the sight will be used. | | |
-| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` |
-| quality | `number` | Value indicating image quality for the compression output. | | `0.6` |
-| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` |
-| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` |
+| showCloseButton | `boolean` | Indicates if the close button should be displayed in the HUD on top of the Camera preview. | | `false` |
| allowSkipRetake | `boolean` | If compliance is enabled, this prop indicate if the user is allowed to skip the retaking process if some pictures are not compliant. | | `false` |
+| enableAddDamage | `boolean` | Boolean indicating if the Add Damage feature should be enabled or not. | | `true` |
+| sightGuidelines | `sightGuideline[]` | A collection of sight guidelines in different language with a list of sightIds associate to it. | | |
+| enableSightGuideline | `boolean` | Boolean indicating whether the sight guideline feature is enabled. If disabled, the guideline text will be hidden. | | `true` |
+| enableTutorial | `PhotoCaptureTutorialOption` | Options for displaying the photo capture tutorial. | | `PhotoCaptureTutorialOption.FIRST_TIME_ONLY` |
+| allowSkipTutorial | `boolean` | Boolean indicating if the user can skip the PhotoCapture tutorial. | | `true` |
+| enableSightTutorial | `boolean` | Boolean indicating whether the sight tutorial feature is enabled. | | `true` |
| enableCompliance | `boolean` | Indicates if compliance checks should be enabled or not. | | `true` |
| enableCompliancePerSight | `string[]` | Array of Sight IDs that indicates for which sight IDs the compliance should be enabled. | | |
| complianceIssues | `ComplianceIssue[]` | If compliance checks are enabled, this property can be used to select a list of compliance issues to check. | | `DEFAULT_COMPLIANCE_ISSUES` |
@@ -88,11 +90,62 @@ export function MonkPhotoCapturePage({ authToken }) {
| useLiveCompliance | `boolean` | Indicates if live compliance should be enabled or not. | | `false` |
| customComplianceThresholds | `CustomComplianceThresholds` | Custom thresholds that can be used to modify the strictness of the compliance for certain compliance issues. | | |
| customComplianceThresholdsPerSight | `Record` | A map associating Sight IDs to custom compliance thresholds. | | |
+| onClose | `() => void` | Callback called when the user clicks on the Close button. If this callback is not provided, the button will not be displayed on the screen. | | |
+| onPictureTaken | `(picture: MonkPicture) => void` | Callback called when the user has taken a picture in the Capture process. | | |
| validateButtonLabel | `string` | Custom label for validate button in gallery view. | | |
-| enableSightGuideline | `boolean` | Boolean indicating whether the sight guideline feature is enabled. If disabled, the guideline text will be hidden. | | `true` |
-| sightGuidelines | `sightGuideline[]` | A collection of sight guidelines in different language with a list of sightIds associate to it. | | |
-| enableTutorial | `PhotoCaptureTutorialOption` | Options for displaying the photo capture tutorial. | | `PhotoCaptureTutorialOption.FIRST_TIME_ONLY` |
-| allowSkipTutorial | `boolean` | Boolean indicating if the user can skip the PhotoCapture tutorial. | | `true` |
-| thumbnailDomain | `string` | The API domain used to communicate with the resize micro service. | ✔️ | |
+| maxUploadDurationWarning | `number` | Max upload duration in milliseconds before showing a bad connection warning to the user. Use `-1` to never display the warning. | | `15000` |
+| useAdaptiveImageQuality | `boolean` | Boolean indicating if the image quality should be downgraded automatically in case of low connection. | | `true` |
+
+# VideoCapture
+The VideoCapture workflow is aimed at asking users to record a walkaround video of their vehicle (around ~1min per
+video) to send vehicle inspection pictures to the Monk API. In reality, no video is being recorded. Instead, screenshots
+of the camera stream a repeatidly taken while the users walks around their vehicle. An algorithm is run to select the
+best video frames (the less blurry ones), and they are then sent to the Monk API.
+
+Please refer to the [official MonkJs documentation](https://monkvision.github.io/monkjs/docs/photo-capture-workflow) to
+have a detailed overview of the Video Capture workflow.
+
+## VideoCapture component
+This package exports a ready-to-use single-page component called `VideoCapture` that implements the VideoCapture
+workflow. The implementation for it is extremely similar to the `PhotoCapture` component described above. The only
+difference being the configuration options that differ, as well as the fact that you need to pass certain parameters
+when creating your inspection to ake sure it will be able to receive video frames as inputs (if you are creating your
+inspection using the `createInspection` requests of the MonkJs SDK, you can simply set the `isVideoCapture` option to
+`true`).
+
+```tsx
+import { sights } from '@monkvision/sights';
+import { VideoCapture } from '@monkvision/inspection-capture-web';
+
+const apiDomain = 'api.preview.monk.ai/v1';
+export function MonkVideoCapturePage({ authToken }) {
+ return (
+ { /* Navigate to another page */ }}
+ />
+ );
+}
+```
+| Prop | Type | Description | Required | Default Value |
+|------------------------------|------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|---------------------------|
+| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` |
+| quality | `number` | Value indicating image quality for the compression output. | | `0.6` |
+| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` |
+| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` |
+| additionalTasks | `TaskName[]` | An optional list of additional tasks to run on every image of the inspection. | | |
+| startTasksOnComplete | `boolean | TaskName[]` | Value indicating if tasks should be started at the end of the inspection. See the `inspection-capture-web` package doc for more info. | | `true` |
+| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | |
+| inspectionId | string | The ID of the inspection to add images to. Make sure that the user that created the inspection if the same one as the one described in the auth token in the `apiConfig` prop. | ✔️ | |
+| apiConfig | ApiConfig | The api config used to communicate with the API. Make sure that the user described in the auth token is the same one as the one that created the inspection provided in the `inspectionId` prop. | ✔️ | |
+| onComplete | `() => void` | Callback called when inspection capture is complete. | | |
+| lang | string | null | The language to be used by this component. | | `'en'` |
+| minRecordingDuration | `number` | The minimum duration of a recording in milliseconds. | | `15000` |
+| maxRetryCount | `number` | The maximum number of retries for failed image uploads. | | `3` |
+| enableFastWalkingWarning | `boolean` | Boolean indicating if a warning should be shown to the user when they are walking too fast around the vehicle. | | `true` |
+| enablePhoneShakingWarning | `boolean` | Boolean indicating if a warning should be shown to the user when they are shaking their phone too much. | | `true` |
+| fastWalkingWarningCooldown | `number` | The duration (in milliseconds) to wait between fast walking warnings. | | `4000` |
+| phoneShakingWarningCooldown | `number` | The duration (in milliseconds) to wait between phone shaking warnings. | | `4000` |
diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.styles.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.styles.ts
index 64345479c..3039105ef 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.styles.ts
+++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.styles.ts
@@ -5,28 +5,4 @@ export const styles: Styles = {
height: '100%',
width: '100%',
},
- orientationErrorContainer: {
- height: '100%',
- width: '100%',
- display: 'flex',
- justifyContent: 'center',
- alignItems: 'center',
- flexDirection: 'column',
- boxSizing: 'border-box',
- padding: '50px 10%',
- },
- orientationErrorTitleContainer: {
- display: 'flex',
- alignItems: 'center',
- },
- orientationErrorTitle: {
- fontSize: 18,
- marginLeft: 16,
- },
- orientationErrorDescription: {
- fontSize: 16,
- paddingTop: 16,
- opacity: 0.8,
- textAlign: 'center',
- },
};
diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx
index c0af662b7..7f156294f 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx
+++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx
@@ -1,15 +1,8 @@
import { useAnalytics } from '@monkvision/analytics';
-import { Camera, CameraHUDProps, CameraProps } from '@monkvision/camera-web';
-import {
- useI18nSync,
- useLoadingState,
- useObjectMemo,
- usePreventExit,
- useWindowDimensions,
-} from '@monkvision/common';
+import { Camera, CameraHUDProps } from '@monkvision/camera-web';
+import { useI18nSync, useLoadingState, useObjectMemo, usePreventExit } from '@monkvision/common';
import {
BackdropDialog,
- Icon,
InspectionGallery,
NavigateToCaptureOptions,
NavigateToCaptureReason,
@@ -18,11 +11,9 @@ import { useMonitoring } from '@monkvision/monitoring';
import { MonkApiConfig } from '@monkvision/network';
import {
CameraConfig,
- CaptureAppConfig,
ComplianceOptions,
- CompressionOptions,
- DeviceOrientation,
MonkPicture,
+ PhotoCaptureAppConfig,
PhotoCaptureTutorialOption,
Sight,
} from '@monkvision/types';
@@ -30,6 +21,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { styles } from './PhotoCapture.styles';
import { PhotoCaptureHUD, PhotoCaptureHUDProps } from './PhotoCaptureHUD';
+import { useStartTasksOnComplete } from '../hooks';
import {
useAdaptiveCameraConfig,
useAddDamageMode,
@@ -39,7 +31,6 @@ import {
usePhotoCaptureSightState,
usePhotoCaptureTutorial,
usePictureTaken,
- useStartTasksOnComplete,
useTracking,
useUploadQueue,
} from './hooks';
@@ -48,9 +39,8 @@ import {
* Props of the PhotoCapture component.
*/
export interface PhotoCaptureProps
- extends Pick, 'resolution' | 'allowImageUpscaling'>,
- Pick<
- CaptureAppConfig,
+ extends Pick<
+ PhotoCaptureAppConfig,
| keyof CameraConfig
| 'maxUploadDurationWarning'
| 'useAdaptiveImageQuality'
@@ -67,7 +57,6 @@ export interface PhotoCaptureProps
| 'allowSkipTutorial'
| 'enableSightTutorial'
>,
- Partial,
Partial {
/**
* The list of sights to take pictures of. The values in this array should be retreived from the `@monkvision/sights`
@@ -160,7 +149,6 @@ export function PhotoCapture({
const { t } = useTranslation();
const monitoring = useMonitoring();
const [currentScreen, setCurrentScreen] = useState(PhotoCaptureScreen.CAMERA);
- const dimensions = useWindowDimensions();
const analytics = useAnalytics();
const loading = useLoadingState();
const addDamageHandle = useAddDamageMode();
@@ -255,9 +243,6 @@ export function PhotoCapture({
monitoring.handleError(err);
});
};
- const isViolatingEnforcedOrientation =
- enforceOrientation &&
- (enforceOrientation === DeviceOrientation.PORTRAIT) !== dimensions.isPortrait;
const hudProps: Omit = {
sights,
selectedSight: sightState.selectedSight,
@@ -282,22 +267,12 @@ export function PhotoCapture({
onNextTutorialStep: goToNextTutorialStep,
onCloseTutorial: closeTutorial,
allowSkipTutorial,
+ enforceOrientation,
};
return (
- {currentScreen === PhotoCaptureScreen.CAMERA && isViolatingEnforcedOrientation && (
-
-
-
-
{t('photo.orientationError.title')}
-
-
- {t('photo.orientationError.description')}
-
-
- )}
- {currentScreen === PhotoCaptureScreen.CAMERA && !isViolatingEnforcedOrientation && (
+ {currentScreen === PhotoCaptureScreen.CAMERA && (
{
/**
* The inspection ID.
@@ -131,6 +133,7 @@ export function PhotoCaptureHUD({
allowSkipTutorial,
onNextTutorialStep,
onCloseTutorial,
+ enforceOrientation,
}: PhotoCaptureHUDProps) {
const { t } = useTranslation();
const [showCloseModal, setShowCloseModal] = useState(false);
@@ -212,6 +215,7 @@ export function PhotoCaptureHUD({
sightId={selectedSight.id}
sightGuidelines={sightGuidelines}
/>
+
);
}
diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx
index 7d68858db..c0af87444 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx
+++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements/PhotoCaptureHUDElements.tsx
@@ -1,4 +1,4 @@
-import { CaptureAppConfig, Image, PixelDimensions, Sight } from '@monkvision/types';
+import { PhotoCaptureAppConfig, Image, PixelDimensions, Sight } from '@monkvision/types';
import { PhotoCaptureMode, TutorialSteps } from '../../hooks';
import { PhotoCaptureHUDElementsSight } from '../PhotoCaptureHUDElementsSight';
import { PhotoCaptureHUDElementsAddDamage1stShot } from '../PhotoCaptureHUDElementsAddDamage1stShot';
@@ -8,7 +8,10 @@ import { PhotoCaptureHUDElementsAddDamage2ndShot } from '../PhotoCaptureHUDEleme
* Props of the PhotoCaptureHUDElements component.
*/
export interface PhotoCaptureHUDElementsProps
- extends Pick {
+ extends Pick<
+ PhotoCaptureAppConfig,
+ 'enableSightGuidelines' | 'sightGuidelines' | 'enableAddDamage'
+ > {
/**
* The currently selected sight in the PhotoCapture component : the sight that the user needs to capture.
*/
diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/SightGuideline/SightGuideline.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/SightGuideline/SightGuideline.tsx
index b0415afe0..8f8c3306d 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/SightGuideline/SightGuideline.tsx
+++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/SightGuideline/SightGuideline.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Button } from '@monkvision/common-ui-web';
-import { CaptureAppConfig } from '@monkvision/types';
+import { PhotoCaptureAppConfig } from '@monkvision/types';
import { useTranslation } from 'react-i18next';
import { getLanguage } from '@monkvision/common';
import { usePhotoCaptureHUDButtonBackground } from '../../hooks';
@@ -10,7 +10,10 @@ import { styles } from './SightGuideline.styles';
* Props of the SightGuideline component.
*/
export interface SightGuidelineProps
- extends Pick {
+ extends Pick<
+ PhotoCaptureAppConfig,
+ 'enableAddDamage' | 'sightGuidelines' | 'enableSightGuidelines'
+ > {
/**
* The id of the sight.
*/
diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts
index 99513078e..13c214b6a 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts
+++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElementsSight/hooks.ts
@@ -1,4 +1,4 @@
-import { CaptureAppConfig, Image, PixelDimensions, Sight } from '@monkvision/types';
+import { PhotoCaptureAppConfig, Image, PixelDimensions, Sight } from '@monkvision/types';
import { useResponsiveStyle } from '@monkvision/common';
import { CSSProperties } from 'react';
import { styles } from './PhotoCaptureHUDElementsSight.styles';
@@ -8,7 +8,10 @@ import { TutorialSteps } from '../../hooks';
* Props of the PhotoCaptureHUDElementsSight component.
*/
export interface PhotoCaptureHUDElementsSightProps
- extends Pick {
+ extends Pick<
+ PhotoCaptureAppConfig,
+ 'enableSightGuidelines' | 'sightGuidelines' | 'enableAddDamage'
+ > {
/**
* The list of sights provided to the PhotoCapture component.
*/
diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.tsx
index f05e810b3..783917224 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.tsx
+++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDTutorial/PhotoCaptureHUDTutorial.tsx
@@ -1,7 +1,7 @@
import { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@monkvision/common-ui-web';
-import { CaptureAppConfig } from '@monkvision/types';
+import { PhotoCaptureAppConfig } from '@monkvision/types';
import { styles } from './PhotoCaptureHUDTutorial.styles';
import { TutorialSteps } from '../../hooks';
import { usePhotoCaptureHUDButtonBackground } from '../hooks';
@@ -13,7 +13,7 @@ import { DisplayText } from './DisplayText';
* Props of the PhotoCaptureHUDTutorial component.
*/
export interface PhotoCaptureHUDTutorialProps
- extends Pick {
+ extends Pick {
/**
* The id of the sight.
*/
diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts
index fb15e6ae6..b6937e82b 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts
+++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/index.ts
@@ -1,5 +1,4 @@
export * from './usePhotoCaptureSightState';
-export * from './useStartTasksOnComplete';
export * from './useAddDamageMode';
export * from './useUploadQueue';
export * from './usePictureTaken';
diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useAdaptiveCameraConfig.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useAdaptiveCameraConfig.ts
index 035f12be6..95c60b2d8 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useAdaptiveCameraConfig.ts
+++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useAdaptiveCameraConfig.ts
@@ -1,7 +1,7 @@
import {
CameraConfig,
CameraResolution,
- CaptureAppConfig,
+ PhotoCaptureAppConfig,
CompressionFormat,
} from '@monkvision/types';
import { useCallback, useMemo, useState } from 'react';
@@ -18,7 +18,10 @@ const DEFAULT_CAMERA_CONFIG: Required = {
/**
* Props passed to the useAdaptiveCameraConfig hook.
*/
-export type UseAdaptiveCameraConfigOptions = Pick & {
+export type UseAdaptiveCameraConfigOptions = Pick<
+ PhotoCaptureAppConfig,
+ 'useAdaptiveImageQuality'
+> & {
/**
* The camera config passed as a prop to the PhotoCapture component.
*/
diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useBadConnectionWarning.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useBadConnectionWarning.ts
index 114e92cc4..d5714394d 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useBadConnectionWarning.ts
+++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useBadConnectionWarning.ts
@@ -1,13 +1,13 @@
import { useCallback, useRef, useState } from 'react';
import { useObjectMemo } from '@monkvision/common';
-import { CaptureAppConfig } from '@monkvision/types';
+import { PhotoCaptureAppConfig } from '@monkvision/types';
import { UploadEventHandlers } from './useUploadQueue';
/**
* Parameters accepted by the useBadConnectionWarning hook.
*/
export type BadConnectionWarningParams = Required<
- Pick
+ Pick
>;
/**
diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureTutorial.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureTutorial.ts
index b3b1293ad..23a15460a 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureTutorial.ts
+++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureTutorial.ts
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
-import { CaptureAppConfig, PhotoCaptureTutorialOption } from '@monkvision/types';
+import { PhotoCaptureAppConfig, PhotoCaptureTutorialOption } from '@monkvision/types';
import { useObjectMemo } from '@monkvision/common';
export const STORAGE_KEY_PHOTO_CAPTURE_TUTORIAL = '@monk_photoCaptureTutorial';
@@ -42,7 +42,7 @@ function getTutorialState(
*/
export interface PhotoCaptureTutorial
extends Pick<
- CaptureAppConfig,
+ PhotoCaptureAppConfig,
'enableTutorial' | 'enableSightTutorial' | 'enableSightGuidelines'
> {}
diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts
index 1e4aaf280..de9f06ddd 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts
+++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts
@@ -1,6 +1,6 @@
import { Queue, uniq, useQueue } from '@monkvision/common';
import { AddImageOptions, ImageUploadType, MonkApiConfig, useMonkApi } from '@monkvision/network';
-import { CaptureAppConfig, ComplianceOptions, MonkPicture, TaskName } from '@monkvision/types';
+import { PhotoCaptureAppConfig, ComplianceOptions, MonkPicture, TaskName } from '@monkvision/types';
import { useRef } from 'react';
import { useMonitoring } from '@monkvision/monitoring';
import { PhotoCaptureMode } from './useAddDamageMode';
@@ -24,7 +24,7 @@ export interface UploadEventHandlers {
/**
* Parameters of the useUploadQueue hook.
*/
-export interface UploadQueueParams extends Pick {
+export interface UploadQueueParams extends Pick {
/**
* The inspection ID.
*/
@@ -106,7 +106,7 @@ function createAddImageOptions(
inspectionId: string,
siblingId: number,
enableThumbnail: boolean,
- additionalTasks?: CaptureAppConfig['additionalTasks'],
+ additionalTasks?: PhotoCaptureAppConfig['additionalTasks'],
compliance?: ComplianceOptions,
): AddImageOptions {
if (upload.mode === PhotoCaptureMode.SIGHT) {
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.styles.ts
new file mode 100644
index 000000000..3039105ef
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.styles.ts
@@ -0,0 +1,8 @@
+import { Styles } from '@monkvision/types';
+
+export const styles: Styles = {
+ container: {
+ height: '100%',
+ width: '100%',
+ },
+};
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx
new file mode 100644
index 000000000..c0478351d
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx
@@ -0,0 +1,141 @@
+import {
+ useDeviceOrientation,
+ useI18nSync,
+ useLoadingState,
+ usePreventExit,
+} from '@monkvision/common';
+import { useState } from 'react';
+import { Camera, CameraHUDProps } from '@monkvision/camera-web';
+import { MonkApiConfig } from '@monkvision/network';
+import { CameraConfig, VideoCaptureAppConfig } from '@monkvision/types';
+import { useMonitoring } from '@monkvision/monitoring';
+import { styles } from './VideoCapture.styles';
+import { VideoCapturePermissions } from './VideoCapturePermissions';
+import { VideoCaptureHUD, VideoCaptureHUDProps } from './VideoCaptureHUD';
+import { useStartTasksOnComplete } from '../hooks';
+import { useFastMovementsDetection } from './hooks';
+
+/**
+ * Props of the VideoCapture component.
+ */
+export interface VideoCaptureProps
+ extends Pick<
+ VideoCaptureAppConfig,
+ | keyof CameraConfig
+ | 'additionalTasks'
+ | 'startTasksOnComplete'
+ | 'enforceOrientation'
+ | 'minRecordingDuration'
+ | 'maxRetryCount'
+ | 'enableFastWalkingWarning'
+ | 'enablePhoneShakingWarning'
+ | 'fastWalkingWarningCooldown'
+ | 'phoneShakingWarningCooldown'
+ > {
+ /**
+ * The ID of the inspection to add the video frames to.
+ */
+ inspectionId: string;
+ /**
+ * The api config used to communicate with the API. Make sure that the user described in the auth token is the same
+ * one as the one that created the inspection provided in the `inspectionId` prop.
+ */
+ apiConfig: MonkApiConfig;
+ /**
+ * Callback called when the inspection is complete.
+ */
+ onComplete?: () => void;
+ /**
+ * The language to be used by this component.
+ *
+ * @default en
+ */
+ lang?: string | null;
+}
+
+enum VideoCaptureScreen {
+ PERMISSIONS = 'permissions',
+ CAPTURE = 'capture',
+}
+
+// No ts-doc for this component : the component exported is VideoCaptureHOC
+export function VideoCapture({
+ inspectionId,
+ apiConfig,
+ additionalTasks,
+ startTasksOnComplete,
+ enforceOrientation,
+ minRecordingDuration = 15000,
+ maxRetryCount = 3,
+ enableFastWalkingWarning = true,
+ enablePhoneShakingWarning = true,
+ fastWalkingWarningCooldown = 1000,
+ phoneShakingWarningCooldown = 1000,
+ onComplete,
+ lang,
+}: VideoCaptureProps) {
+ useI18nSync(lang);
+ const [screen, setScreen] = useState(VideoCaptureScreen.PERMISSIONS);
+ const [isRecording, setIsRecording] = useState(false);
+ const { handleError } = useMonitoring();
+ const { onDeviceOrientationEvent, fastMovementsWarning, onWarningDismiss } =
+ useFastMovementsDetection({
+ isRecording,
+ enableFastWalkingWarning,
+ enablePhoneShakingWarning,
+ fastWalkingWarningCooldown,
+ phoneShakingWarningCooldown,
+ });
+ const { alpha, requestCompassPermission } = useDeviceOrientation({ onDeviceOrientationEvent });
+ const startTasksLoading = useLoadingState();
+
+ const startTasks = useStartTasksOnComplete({
+ inspectionId,
+ apiConfig,
+ additionalTasks,
+ startTasksOnComplete,
+ loading: startTasksLoading,
+ });
+ const { allowRedirect } = usePreventExit(true);
+
+ const handleComplete = () => {
+ startTasks()
+ .then(() => {
+ allowRedirect();
+ onComplete?.();
+ })
+ .catch((err) => {
+ startTasksLoading.onError(err);
+ handleError(err);
+ });
+ };
+
+ const hudProps: Omit = {
+ inspectionId,
+ maxRetryCount,
+ apiConfig,
+ minRecordingDuration,
+ enforceOrientation,
+ isRecording,
+ setIsRecording,
+ alpha,
+ fastMovementsWarning,
+ onWarningDismiss,
+ startTasksLoading,
+ onComplete: handleComplete,
+ };
+
+ return (
+
+ {screen === VideoCaptureScreen.PERMISSIONS && (
+ setScreen(VideoCaptureScreen.CAPTURE)}
+ />
+ )}
+ {screen === VideoCaptureScreen.CAPTURE && (
+
+ )}
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHOC.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHOC.tsx
new file mode 100644
index 000000000..4f11bcdde
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHOC.tsx
@@ -0,0 +1,35 @@
+import { i18nWrap, MonkProvider } from '@monkvision/common';
+import { i18nInspectionCaptureWeb } from '../i18n';
+import { VideoCapture, VideoCaptureProps } from './VideoCapture';
+
+/**
+ * The VideoCapture component is a ready-to-use, single page component that implements a Camera app and lets the user
+ * record a video of their vehicle in order to add them to an already created Monk inspection. In order to use this
+ * component, you first need to generate an Auth0 authentication token, and create an inspection using the Monk Api.
+ * When creating the inspection, don't forget to set the tasks statuses to `NOT_STARTED`. This component will handle the
+ * starting of the tasks at the end of the capturing process. You can then pass the inspection ID, the api config (with
+ * the auth token) and everything will be handled automatically for you.
+ *
+ * @example
+ * import { VideoCapture } from '@monkvision/inspection-capture-web';
+ *
+ * export function VideoCaptureScreen({ inspectionId, apiConfig }: VideoCaptureScreenProps) {
+ * const { i18n } = useTranslation();
+ *
+ * return (
+ * { / * Navigate to another page * / }}
+ * lang={i18n.language}
+ * />
+ * );
+ * }
+ */
+export const VideoCaptureHOC = i18nWrap(function VideoCaptureHOC(props: VideoCaptureProps) {
+ return (
+
+
+
+ );
+}, i18nInspectionCaptureWeb);
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.styles.ts
new file mode 100644
index 000000000..711680d6a
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.styles.ts
@@ -0,0 +1,13 @@
+import { Styles } from '@monkvision/types';
+
+export const styles: Styles = {
+ container: {
+ width: '100%',
+ height: '100%',
+ position: 'relative',
+ },
+ hudContainer: {
+ position: 'absolute',
+ inset: '0 0 0 0',
+ },
+};
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx
new file mode 100644
index 000000000..5cd0ee664
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx
@@ -0,0 +1,225 @@
+import { Dispatch, SetStateAction, useEffect, useState } from 'react';
+import { CameraHUDProps } from '@monkvision/camera-web';
+import { BackdropDialog } from '@monkvision/common-ui-web';
+import { useTranslation } from 'react-i18next';
+import { ImageUploadType, MonkApiConfig, useMonkApi } from '@monkvision/network';
+import { LoadingState } from '@monkvision/common';
+import { DeviceRotation, VideoCaptureAppConfig } from '@monkvision/types';
+import { useMonitoring } from '@monkvision/monitoring';
+import { styles } from './VideoCaptureHUD.styles';
+import { VideoCaptureTutorial } from './VideoCaptureTutorial';
+import { VideoCaptureRecording } from './VideoCaptureRecording';
+import {
+ FastMovementsDetectionHandle,
+ FastMovementType,
+ useFrameSelection,
+ useVehicleWalkaround,
+ useVideoRecording,
+ UseVideoRecordingParams,
+ useVideoUploadQueue,
+ VideoRecordingTooltip,
+} from '../hooks';
+import { VideoCaptureProcessing } from '../VideoCaptureProcessing';
+import { OrientationEnforcer } from '../../components';
+
+/**
+ * Props accepted by the VideoCaptureHUD component.
+ */
+export interface VideoCaptureHUDProps
+ extends CameraHUDProps,
+ Pick,
+ Pick,
+ Pick,
+ Pick {
+ /**
+ * The ID of the inspection to add the video frames to.
+ */
+ inspectionId: string;
+ /**
+ * The api config used to communicate with the API. Make sure that the user described in the auth token is the same
+ * one as the one that created the inspection provided in the `inspectionId` prop.
+ */
+ apiConfig: MonkApiConfig;
+ /**
+ * The maximum number of retries for failed image uploads.
+ */
+ maxRetryCount: number;
+ /**
+ * Boolean indicating if the video is currently recording or not.
+ */
+ isRecording: boolean;
+ /**
+ * Callback called when setting the `isRecording` state.
+ */
+ setIsRecording: Dispatch>;
+ /**
+ * The loading state for the start task feature.
+ */
+ startTasksLoading: LoadingState;
+ /**
+ * Callback called when the inspection capture is complete.
+ */
+ onComplete?: () => void;
+}
+
+const SCREENSHOT_INTERVAL_MS = 200;
+const FRAME_SELECTION_INTERVAL_MS = 1000;
+
+enum VideoCaptureHUDScreen {
+ TUTORIAL = 'tutorial',
+ RECORDING = 'recording',
+ PROCESSING = 'processing',
+}
+
+function getFastMovementsWarningMessage(type: FastMovementType | null): string {
+ switch (type) {
+ case FastMovementType.WALKING_TOO_FAST:
+ return 'video.recording.fastMovementsDialog.walkingTooFast';
+ case FastMovementType.PHONE_SHAKING:
+ return 'video.recording.fastMovementsDialog.phoneShaking';
+ default:
+ return '';
+ }
+}
+
+function getTooltipLabel(tooltip: VideoRecordingTooltip | null): string | undefined {
+ switch (tooltip) {
+ case VideoRecordingTooltip.START:
+ return 'video.recording.tooltip.start';
+ case VideoRecordingTooltip.END:
+ return 'video.recording.tooltip.end';
+ default:
+ return undefined;
+ }
+}
+
+/**
+ * HUD component displayed on top of the camera preview for the VideoCapture process.
+ */
+export function VideoCaptureHUD({
+ handle,
+ cameraPreview,
+ inspectionId,
+ apiConfig,
+ isRecording,
+ setIsRecording,
+ enforceOrientation,
+ alpha,
+ fastMovementsWarning,
+ onWarningDismiss,
+ maxRetryCount,
+ minRecordingDuration,
+ startTasksLoading,
+ onComplete,
+}: VideoCaptureHUDProps) {
+ const [screen, setScreen] = useState(VideoCaptureHUDScreen.TUTORIAL);
+ const { t } = useTranslation();
+ const { handleError } = useMonitoring();
+ const { walkaroundPosition, startWalkaround } = useVehicleWalkaround({ alpha });
+ const { addImage } = useMonkApi(apiConfig);
+
+ const { uploadedFrames, totalUploadingFrames, onFrameSelected } = useVideoUploadQueue({
+ apiConfig,
+ inspectionId,
+ maxRetryCount,
+ });
+
+ const { processedFrames, totalProcessingFrames, onCaptureVideoFrame } = useFrameSelection({
+ handle,
+ frameSelectionInterval: FRAME_SELECTION_INTERVAL_MS,
+ onFrameSelected,
+ });
+ const {
+ isRecordingPaused,
+ onClickRecordVideo,
+ onDiscardDialogKeepRecording,
+ onDiscardDialogDiscardVideo,
+ isDiscardDialogDisplayed,
+ recordingDurationMs,
+ pauseRecording,
+ resumeRecording,
+ tooltip,
+ } = useVideoRecording({
+ isRecording,
+ setIsRecording,
+ screenshotInterval: SCREENSHOT_INTERVAL_MS,
+ minRecordingDuration,
+ enforceOrientation,
+ walkaroundPosition,
+ startWalkaround,
+ onCaptureVideoFrame,
+ onRecordingComplete: () => setScreen(VideoCaptureHUDScreen.PROCESSING),
+ });
+
+ const handleTakePictureClick = async () => {
+ try {
+ const picture = await handle.takePicture();
+ await addImage({
+ uploadType: ImageUploadType.VIDEO_MANUAL_PHOTO,
+ inspectionId,
+ picture,
+ });
+ } catch (err) {
+ handleError(err);
+ }
+ };
+
+ useEffect(() => {
+ if (fastMovementsWarning) {
+ pauseRecording();
+ } else {
+ resumeRecording();
+ }
+ }, [fastMovementsWarning, pauseRecording, resumeRecording]);
+
+ return (
+
+ {cameraPreview}
+
+ {screen === VideoCaptureHUDScreen.TUTORIAL && (
+ setScreen(VideoCaptureHUDScreen.RECORDING)} />
+ )}
+ {screen === VideoCaptureHUDScreen.RECORDING && (
+
+ )}
+ {screen === VideoCaptureHUDScreen.PROCESSING && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx
new file mode 100644
index 000000000..b7ab52463
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx
@@ -0,0 +1,68 @@
+import {
+ RecordVideoButton,
+ TakePictureButton,
+ VehicleWalkaroundIndicator,
+} from '@monkvision/common-ui-web';
+import { useVideoCaptureRecordingStyles } from './VideoCaptureRecordingStyles';
+import { VideoCaptureRecordingProps } from './VideoCaptureRecording.types';
+
+function formatRecordingDuration(durationMs: number): string {
+ const totalSeconds = Math.floor(durationMs / 1000);
+ const totalMinutes = Math.floor(totalSeconds / 60);
+ const remainingSeconds = totalSeconds % 60;
+ return `${totalMinutes.toString().padStart(2, '0')}:${remainingSeconds
+ .toString()
+ .padStart(2, '0')}`;
+}
+
+/**
+ * HUD used in recording mode displayed on top of the camera in the VideoCaputre process.
+ */
+export function VideoCaptureRecording({
+ walkaroundPosition,
+ isRecording,
+ isRecordingPaused,
+ recordingDurationMs,
+ onClickRecordVideo,
+ onClickTakePicture,
+ tooltip,
+}: VideoCaptureRecordingProps) {
+ const {
+ container,
+ indicators,
+ recordingDuration,
+ controls,
+ takePictureFlash,
+ walkaroundIndicator,
+ showTakePictureFlash,
+ tooltipPosition,
+ } = useVideoCaptureRecordingStyles({ isRecording });
+
+ const handleTakePictureClick = () => {
+ showTakePictureFlash();
+ onClickTakePicture?.();
+ };
+
+ return (
+
+
+ {(isRecording || isRecordingPaused) && (
+
{formatRecordingDuration(recordingDurationMs)}
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts
new file mode 100644
index 000000000..d77efbe26
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts
@@ -0,0 +1,33 @@
+/**
+ * Props accepted by the VideoCaptureRecording component.
+ */
+export interface VideoCaptureRecordingProps {
+ /**
+ * The rotation of the user aroundn the vehicle in deg.
+ */
+ walkaroundPosition: number;
+ /**
+ * Boolean indicating if the video is currently recording or not.
+ */
+ isRecording: boolean;
+ /**
+ * Boolean indicating if the video recording is paused or not.
+ */
+ isRecordingPaused: boolean;
+ /**
+ * The total duration (in milliseconds) of the current video recording.
+ */
+ recordingDurationMs: number;
+ /**
+ * Callback called when the user clicks on the record video button.
+ */
+ onClickRecordVideo?: () => void;
+ /**
+ * Callback called when the user clicks on the take picture button.
+ */
+ onClickTakePicture?: () => void;
+ /**
+ * The tooltip to display on top of the recording button.
+ */
+ tooltip?: string;
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts
new file mode 100644
index 000000000..2cfd548a0
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts
@@ -0,0 +1,122 @@
+import { Styles } from '@monkvision/types';
+import {
+ useIsMounted,
+ useMonkTheme,
+ useResponsiveStyle,
+ useWindowDimensions,
+} from '@monkvision/common';
+import { useState } from 'react';
+import { RecordVideoButtonProps } from '@monkvision/common-ui-web';
+import { VideoCaptureRecordingProps } from './VideoCaptureRecording.types';
+
+export const styles: Styles = {
+ container: {
+ width: '100%',
+ height: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ alignSelf: 'stretch',
+ },
+ containerLandscape: {
+ __media: { landscape: true },
+ flexDirection: 'row',
+ },
+ indicators: {
+ alignSelf: 'stretch',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'end',
+ flexDirection: 'row',
+ padding: 20,
+ },
+ indicatorsLandscape: {
+ __media: { landscape: true },
+ justifyContent: 'start',
+ flexDirection: 'column',
+ },
+ recordingDuration: {
+ padding: 10,
+ borderRadius: 9999,
+ },
+ controls: {
+ alignSelf: 'stretch',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ flexDirection: 'row',
+ padding: '0 20px 32px 20px',
+ },
+ controlsLandscape: {
+ __media: { landscape: true },
+ flexDirection: 'column-reverse',
+ padding: '20px 32px 20px 0',
+ },
+ walkaroundIndicatorDisabled: {
+ opacity: 0.7,
+ filter: 'grayscale(1)',
+ },
+ takePictureFlash: {
+ position: 'fixed',
+ inset: '0 0 0 0',
+ zIndex: 999,
+ backgroundColor: '#efefef',
+ opacity: 0,
+ transition: 'opacity 0.5s ease-out',
+ pointerEvents: 'none',
+ },
+ takePictureFlashVisible: {
+ opacity: 1,
+ transition: 'none',
+ },
+};
+
+export function useVideoCaptureRecordingStyles({
+ isRecording,
+}: Pick) {
+ const [isTakePictureFlashVisible, setTakePictureFlashVisible] = useState(false);
+ const { palette } = useMonkTheme();
+ const { responsive } = useResponsiveStyle();
+ const { isPortrait } = useWindowDimensions();
+
+ const isMounted = useIsMounted();
+
+ const showTakePictureFlash = () => {
+ setTakePictureFlashVisible(true);
+ setTimeout(() => {
+ if (isMounted()) {
+ setTakePictureFlashVisible(false);
+ }
+ }, 100);
+ };
+
+ return {
+ container: {
+ ...styles['container'],
+ ...responsive(styles['containerLandscape']),
+ },
+ indicators: {
+ ...styles['indicators'],
+ ...responsive(styles['indicatorsLandscape']),
+ },
+ recordingDuration: {
+ ...styles['recordingDuration'],
+ color: palette.text.primary,
+ backgroundColor: palette.alert.base,
+ },
+ controls: {
+ ...styles['controls'],
+ ...responsive(styles['controlsLandscape']),
+ },
+ takePictureFlash: {
+ ...styles['takePictureFlash'],
+ ...(isTakePictureFlashVisible ? styles['takePictureFlashVisible'] : {}),
+ },
+ walkaroundIndicator: {
+ ...(isRecording ? {} : styles['walkaroundIndicatorDisabled']),
+ },
+ showTakePictureFlash,
+ tooltipPosition: (isPortrait ? 'up' : 'left') as RecordVideoButtonProps['tooltipPosition'],
+ };
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/index.ts
new file mode 100644
index 000000000..49399f029
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/index.ts
@@ -0,0 +1,2 @@
+export { VideoCaptureRecording } from './VideoCaptureRecording';
+export { type VideoCaptureRecordingProps } from './VideoCaptureRecording.types';
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/VideoCaptureTutorial.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/VideoCaptureTutorial.tsx
new file mode 100644
index 000000000..e34985d7a
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/VideoCaptureTutorial.tsx
@@ -0,0 +1,44 @@
+import { useTranslation } from 'react-i18next';
+import { PageLayoutItem, VideoCapturePageLayout } from '../../VideoCapturePageLayout';
+
+/**
+ * Props accepted by the VideoCaptureTutorial component.
+ */
+export interface VideoCaptureTutorialProps {
+ /**
+ * Callback called when the user closes the tutorial by clicking on the confirm button.
+ */
+ onClose?: () => void;
+}
+
+/**
+ * This component is a tutorial displayed on top of the camera when the user first starts the video capture.
+ */
+export function VideoCaptureTutorial({ onClose }: VideoCaptureTutorialProps) {
+ const { t } = useTranslation();
+
+ const confirmButtonProps = {
+ onClick: onClose,
+ children: t('video.tutorial.confirm'),
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/index.ts
new file mode 100644
index 000000000..cf972d80c
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/index.ts
@@ -0,0 +1 @@
+export { VideoCaptureTutorial, type VideoCaptureTutorialProps } from './VideoCaptureTutorial';
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/index.ts
new file mode 100644
index 000000000..798cafa75
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/index.ts
@@ -0,0 +1 @@
+export { VideoCaptureHUD, type VideoCaptureHUDProps } from './VideoCaptureHUD';
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.styles.ts
new file mode 100644
index 000000000..d195e5eb4
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.styles.ts
@@ -0,0 +1,79 @@
+import { CSSProperties } from 'react';
+import { Styles } from '@monkvision/types';
+import { useMonkTheme, useResponsiveStyle } from '@monkvision/common';
+import { IconProps } from '@monkvision/common-ui-web';
+
+export const PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT = 600;
+
+export const styles: Styles = {
+ container: {
+ alignSelf: 'stretch',
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'center',
+ margin: 16,
+ },
+ icon: {
+ marginRight: 12,
+ },
+ labels: {
+ flex: 1,
+ alignSelf: 'stretch',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ title: {
+ fontSize: 20,
+ fontWeight: 500,
+ },
+ titleSmall: {
+ __media: {
+ maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT,
+ },
+ fontSize: 14,
+ fontWeight: 500,
+ },
+ description: {
+ fontSize: 18,
+ paddingTop: 6,
+ opacity: 0.91,
+ fontWeight: 300,
+ },
+ descriptionSmall: {
+ __media: {
+ maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT,
+ },
+ fontSize: 12,
+ fontWeight: 400,
+ },
+};
+
+interface PageLayoutItemStyle {
+ iconProps: Partial;
+ titleStyle: CSSProperties;
+ descriptionStyle: CSSProperties;
+}
+
+export function usePageLayoutItemStyles(): PageLayoutItemStyle {
+ const { palette } = useMonkTheme();
+ const { responsive } = useResponsiveStyle();
+
+ return {
+ iconProps: {
+ size: 40,
+ primaryColor: palette.primary.base,
+ style: {
+ ...styles['icon'],
+ ...responsive(styles['iconSmall']),
+ },
+ },
+ titleStyle: {
+ ...styles['title'],
+ ...responsive(styles['titleSmall']),
+ },
+ descriptionStyle: {
+ ...styles['description'],
+ ...responsive(styles['descriptionSmall']),
+ },
+ };
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.tsx
new file mode 100644
index 000000000..cfd75cc13
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.tsx
@@ -0,0 +1,37 @@
+import { Icon, IconName } from '@monkvision/common-ui-web';
+import { styles, usePageLayoutItemStyles } from './PageLayoutItem.styles';
+
+/**
+ * Props accepted by the PageLayoutItem component.
+ */
+export interface PageLayoutItemProps {
+ /**
+ * The name of the item icon.
+ */
+ icon: IconName;
+ /**
+ * The title of the item.
+ */
+ title: string;
+ /**
+ * The description of the item.
+ */
+ description: string;
+}
+
+/**
+ * A custom list item that is displayed in VideoCapture Intro screens.
+ */
+export function PageLayoutItem({ icon, title, description }: PageLayoutItemProps) {
+ const { iconProps, titleStyle, descriptionStyle } = usePageLayoutItemStyles();
+
+ return (
+
+
+
+
{title}
+
{description}
+
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/index.ts
new file mode 100644
index 000000000..4fe1224ef
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/index.ts
@@ -0,0 +1 @@
+export { PageLayoutItem, type PageLayoutItemProps } from './PageLayoutItem';
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.styles.ts
new file mode 100644
index 000000000..a18fd62a8
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.styles.ts
@@ -0,0 +1,98 @@
+import { Styles } from '@monkvision/types';
+import { DynamicSVGProps } from '@monkvision/common-ui-web';
+import { CSSProperties, useCallback } from 'react';
+import { fullyColorSVG, useMonkTheme, useResponsiveStyle } from '@monkvision/common';
+import { VideoCapturePageLayoutProps } from './VideoCapturePageLayout.types';
+import { PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT } from './PageLayoutItem/PageLayoutItem.styles';
+
+export const styles: Styles = {
+ container: {
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ },
+ containerBackdrop: {
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ },
+ logo: {
+ margin: '32px 0',
+ width: 80,
+ height: 'auto',
+ },
+ logoSmall: {
+ __media: {
+ maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT,
+ },
+ display: 'none',
+ },
+ title: {
+ fontSize: 32,
+ fontWeight: 700,
+ textAlign: 'center',
+ padding: '0 32px 16px 32px',
+ },
+ titleSmall: {
+ __media: {
+ maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT,
+ },
+ fontSize: 20,
+ fontWeight: 700,
+ textAlign: 'center',
+ padding: '10px 16px 10px 16px',
+ },
+ childrenContainer: {
+ flex: 1,
+ alignSelf: 'stretch',
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ confirmContainer: {
+ alignSelf: 'stretch',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ padding: '0 32px 32px 32px',
+ },
+ confirmButton: {
+ alignSelf: 'stretch',
+ maxWidth: 400,
+ },
+};
+
+interface VideoCapturePageLayoutStyles {
+ logoProps: Partial;
+ containerStyle: CSSProperties;
+ titleStyle: CSSProperties;
+}
+
+export function useVideoCapturePageLayoutStyles({
+ showBackdrop,
+}: Required>): VideoCapturePageLayoutStyles {
+ const { palette } = useMonkTheme();
+ const { responsive } = useResponsiveStyle();
+
+ const getLogoAttributes = useCallback(
+ (element: Element) => fullyColorSVG(element, palette.text.primary),
+ [palette],
+ );
+
+ return {
+ logoProps: {
+ getAttributes: getLogoAttributes,
+ style: {
+ ...styles['logo'],
+ ...responsive(styles['logoSmall']),
+ },
+ },
+ containerStyle: {
+ ...styles['container'],
+ ...(showBackdrop ? styles['containerBackdrop'] : {}),
+ },
+ titleStyle: {
+ ...styles['title'],
+ ...responsive(styles['titleSmall']),
+ },
+ };
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.tsx
new file mode 100644
index 000000000..593af247a
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.tsx
@@ -0,0 +1,33 @@
+import { PropsWithChildren } from 'react';
+import { Button, DynamicSVG } from '@monkvision/common-ui-web';
+import { useTranslation } from 'react-i18next';
+import { monkLogoSVG } from '../../assets/logos.asset';
+import { styles, useVideoCapturePageLayoutStyles } from './VideoCapturePageLayout.styles';
+import { VideoCapturePageLayoutProps } from './VideoCapturePageLayout.types';
+
+/**
+ * This component is used to display the same layout for every "default" screen for the VideoCapture process (the
+ * premissions screen, the tutorial etc.).
+ */
+export function VideoCapturePageLayout({
+ showBackdrop = false,
+ showTitle = true,
+ confirmButtonProps,
+ children,
+}: PropsWithChildren) {
+ const { t } = useTranslation();
+ const { logoProps, containerStyle, titleStyle } = useVideoCapturePageLayoutStyles({
+ showBackdrop,
+ });
+
+ return (
+
+
+ {showTitle &&
{t('video.introduction.title')}
}
+
{children}
+
+
+
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.types.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.types.ts
new file mode 100644
index 000000000..c0c9ceac9
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.types.ts
@@ -0,0 +1,23 @@
+import { ButtonProps } from '@monkvision/common-ui-web';
+
+/**
+ * Props accepted by the VideoCapturePageLayout component.
+ */
+export interface VideoCapturePageLayoutProps {
+ /**
+ * Boolean indicating if a black backdrop should be displayed behind the component.
+ *
+ * @default false
+ */
+ showBackdrop?: boolean;
+ /**
+ * Boolean indicating if the title of the page should be displayed or not.
+ *
+ * @default true
+ */
+ showTitle?: boolean;
+ /**
+ * Pass-through props passed down to the confirm button.
+ */
+ confirmButtonProps?: Partial;
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/index.ts
new file mode 100644
index 000000000..ae8a5b9c0
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/index.ts
@@ -0,0 +1,3 @@
+export { VideoCapturePageLayout } from './VideoCapturePageLayout';
+export { type VideoCapturePageLayoutProps } from './VideoCapturePageLayout.types';
+export { PageLayoutItem, type PageLayoutItemProps } from './PageLayoutItem';
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx
new file mode 100644
index 000000000..5b71824d9
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx
@@ -0,0 +1,72 @@
+import { useTranslation } from 'react-i18next';
+import { useIsMounted, useLoadingState } from '@monkvision/common';
+import { useCameraPermission } from '@monkvision/camera-web';
+import { useMonitoring } from '@monkvision/monitoring';
+import { PageLayoutItem, VideoCapturePageLayout } from '../VideoCapturePageLayout';
+
+/**
+ * Props accepted by the VideoCapturePermissions component.
+ */
+export interface VideoCapturePermissionsProps {
+ /**
+ * Callback used to request the compass permission on the device.
+ */
+ requestCompassPermission?: () => Promise;
+ /**
+ * Callback called when the user has successfully granted the required permissions to the app.
+ */
+ onSuccess?: () => void;
+}
+
+/**
+ * Component displayed in the Permissions view of the video capture. Used to make sure the current app has the proper
+ * permissions before moving forward.
+ */
+export function VideoCapturePermissions({
+ requestCompassPermission,
+ onSuccess,
+}: VideoCapturePermissionsProps) {
+ const { t } = useTranslation();
+ const loading = useLoadingState();
+ const { handleError } = useMonitoring();
+ const { requestCameraPermission } = useCameraPermission();
+ const isMounted = useIsMounted();
+
+ const handleConfirm = async () => {
+ loading.start();
+ try {
+ if (requestCompassPermission) {
+ await requestCompassPermission();
+ }
+ await requestCameraPermission();
+ onSuccess?.();
+ if (isMounted()) {
+ loading.onSuccess();
+ }
+ } catch (err) {
+ loading.onError(err);
+ handleError(err);
+ }
+ };
+
+ const confirmButtonProps = {
+ onClick: handleConfirm,
+ loading,
+ children: t('video.permissions.confirm'),
+ };
+
+ return (
+
+
+
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/index.ts
new file mode 100644
index 000000000..03fc70c24
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/index.ts
@@ -0,0 +1,4 @@
+export {
+ VideoCapturePermissions,
+ type VideoCapturePermissionsProps,
+} from './VideoCapturePermissions';
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.styles.ts
new file mode 100644
index 000000000..64f3e4707
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.styles.ts
@@ -0,0 +1,59 @@
+import { Styles } from '@monkvision/types';
+import { useMonkTheme } from '@monkvision/common';
+
+export const styles: Styles = {
+ container: {
+ width: '100%',
+ display: 'flex',
+ alignSelf: 'stretch',
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'column',
+ },
+ labelContainer: {
+ width: '80%',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '0 8px 8px 8px',
+ fontSize: 14,
+ },
+ percentage: {
+ fontFamily: 'monospace',
+ },
+ progressBarContainer: {
+ width: '80%',
+ borderRadius: 9999,
+ borderStyle: 'solid',
+ borderWidth: 1,
+ },
+ progressBar: {
+ height: 10,
+ borderRadius: 9999,
+ },
+ errorMessage: {
+ padding: '0 16px',
+ textAlign: 'center',
+ },
+};
+
+export function useVideoCaptureProcessingStyles(progress: number) {
+ const { palette } = useMonkTheme();
+
+ return {
+ containerStyle: {
+ ...styles['container'],
+ color: palette.text.primary,
+ },
+ progressBarContainerStyle: {
+ ...styles['progressBarContainer'],
+ borderColor: palette.primary.dark,
+ },
+ progressBarStyle: {
+ ...styles['progressBar'],
+ backgroundColor: palette.primary.dark,
+ width: `${progress * 100}%`,
+ },
+ };
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.tsx
new file mode 100644
index 000000000..bd8580b6e
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.tsx
@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next';
+import { ButtonProps } from '@monkvision/common-ui-web';
+import { VideoCaptureProcessingProps } from './VideoCaptureProcessing.types';
+import { VideoCapturePageLayout } from '../VideoCapturePageLayout';
+import { styles, useVideoCaptureProcessingStyles } from './VideoCaptureProcessing.styles';
+
+function getLabel(processingProgress: number, uploadingProgress: number): string {
+ if (processingProgress < 1) {
+ return 'video.processing.processing';
+ }
+ return uploadingProgress < 1 ? 'video.processing.uploading' : 'video.processing.success';
+}
+
+/**
+ * Component displayed at the end of the VideoCapture process, used to display progress indicators for the processing
+ * and uploading of video frames.
+ */
+export function VideoCaptureProcessing({
+ inspectionId,
+ processedFrames,
+ totalProcessingFrames,
+ uploadedFrames,
+ totalUploadingFrames,
+ loading,
+ onComplete,
+}: VideoCaptureProcessingProps) {
+ const processingProgress = processedFrames / totalProcessingFrames;
+ const uploadingProgress = uploadedFrames / totalUploadingFrames;
+ const progress = processingProgress < 1 ? processingProgress : uploadingProgress;
+ const { t } = useTranslation();
+ const { containerStyle, progressBarContainerStyle, progressBarStyle } =
+ useVideoCaptureProcessingStyles(progress);
+
+ const confirmButtonProps: ButtonProps = {
+ onClick: onComplete,
+ loading: loading.isLoading,
+ disabled: processingProgress < 1 || uploadingProgress < 1 || !!loading.error,
+ children: t('video.processing.done'),
+ };
+
+ return (
+
+
+ {!loading.error && (
+ <>
+
+
{t(getLabel(processingProgress, uploadingProgress))}
+
{Math.floor(progress * 100)}%
+
+
+ >
+ )}
+ {loading.error && (
+
+ {t('video.processing.error')} {inspectionId}
+
+ )}
+
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.types.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.types.ts
new file mode 100644
index 000000000..4d8555a25
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.types.ts
@@ -0,0 +1,35 @@
+import { LoadingState } from '@monkvision/common';
+
+/**
+ * Props accepted by the VideoCaptureProcessing component.
+ */
+export interface VideoCaptureProcessingProps {
+ /**
+ * The inspection ID.
+ */
+ inspectionId: string;
+ /**
+ * The number of frames that have successfully been processed and added to the upload queue.
+ */
+ processedFrames: number;
+ /**
+ * The total number of frames added to the processing queue.
+ */
+ totalProcessingFrames: number;
+ /**
+ * The number of frames that have successfully been uploaded to the API.
+ */
+ uploadedFrames: number;
+ /**
+ * The total number of frames added to the uploading queue.
+ */
+ totalUploadingFrames: number;
+ /**
+ * Loading state for the done button.
+ */
+ loading: LoadingState;
+ /**
+ * Callback called when the user presses the confirm button.
+ */
+ onComplete?: () => void;
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/index.ts
new file mode 100644
index 000000000..d7ba27bd0
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/index.ts
@@ -0,0 +1,2 @@
+export { VideoCaptureProcessing } from './VideoCaptureProcessing';
+export { type VideoCaptureProcessingProps } from './VideoCaptureProcessing.types';
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts
new file mode 100644
index 000000000..b471d2637
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts
@@ -0,0 +1,5 @@
+export * from './useVehicleWalkaround';
+export * from './useVideoRecording';
+export * from './useFrameSelection';
+export * from './useVideoUploadQueue';
+export * from './useFastMovementsDetection';
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/fastMovementsDetection.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/fastMovementsDetection.ts
new file mode 100644
index 000000000..99a9ce006
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/fastMovementsDetection.ts
@@ -0,0 +1,51 @@
+import { DeviceRotation } from '@monkvision/types';
+
+const SMOOTH_MOVEMENT_FACTOR = 0.98;
+const ALPHA_DETECTION_MIN = 2.5;
+const ALPHA_DETECTION_MAX = 179;
+const BETA_DETECTION_MIN = 4;
+const BETA_DETECTION_MAX = 89;
+const GAMMA_DETECTION_MIN = 4;
+const GAMMA_DETECTION_MAX = 89;
+
+/**
+ * Enumeration of the different fast movements that can be detected.
+ */
+export enum FastMovementType {
+ /**
+ * The user is walking too fast around the vehicle.
+ */
+ WALKING_TOO_FAST = 'walking_too_fast',
+ /**
+ * The user is shaking their phone too much.
+ */
+ PHONE_SHAKING = 'phone_shaking',
+}
+
+/**
+ * Function used to detect fast user movements between DeviceOrientationEvent emissions.
+ */
+export function detectFastMovements(
+ rotation: DeviceRotation,
+ previousRotation: DeviceRotation,
+): FastMovementType | null {
+ const { alpha, beta, gamma } = rotation;
+ const { alpha: prevAlpha, beta: prevBeta, gamma: prevGamma } = previousRotation;
+ const alphaSpeed = Math.abs(alpha - prevAlpha) * SMOOTH_MOVEMENT_FACTOR;
+ const betaSpeed = Math.abs(beta - prevBeta) * SMOOTH_MOVEMENT_FACTOR;
+ const gammaSpeed = Math.abs(gamma - prevGamma) * SMOOTH_MOVEMENT_FACTOR;
+
+ if (prevBeta !== 0 && betaSpeed > BETA_DETECTION_MIN && betaSpeed < BETA_DETECTION_MAX) {
+ return FastMovementType.PHONE_SHAKING;
+ }
+
+ if (prevGamma !== 0 && gammaSpeed > GAMMA_DETECTION_MIN && gammaSpeed < GAMMA_DETECTION_MAX) {
+ return FastMovementType.PHONE_SHAKING;
+ }
+
+ if (prevAlpha !== 0 && alphaSpeed > ALPHA_DETECTION_MIN && alphaSpeed < ALPHA_DETECTION_MAX) {
+ return FastMovementType.WALKING_TOO_FAST;
+ }
+
+ return null;
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/index.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/index.ts
new file mode 100644
index 000000000..ae74633b3
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/index.ts
@@ -0,0 +1,2 @@
+export { FastMovementType } from './fastMovementsDetection';
+export * from './useFastMovementsDetection';
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/useFastMovementsDetection.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/useFastMovementsDetection.ts
new file mode 100644
index 000000000..52cd07df4
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useFastMovementsDetection/useFastMovementsDetection.ts
@@ -0,0 +1,120 @@
+import { useCallback, useRef, useState } from 'react';
+import { DeviceRotation, VideoCaptureAppConfig } from '@monkvision/types';
+import { useObjectMemo } from '@monkvision/common';
+import { detectFastMovements, FastMovementType } from './fastMovementsDetection';
+
+/**
+ * Params accepted by the useFastMovementsDetection hook.
+ */
+export interface UseFastMovementsDetectionParams
+ extends Required<
+ Pick<
+ VideoCaptureAppConfig,
+ | 'enableFastWalkingWarning'
+ | 'enablePhoneShakingWarning'
+ | 'fastWalkingWarningCooldown'
+ | 'phoneShakingWarningCooldown'
+ >
+ > {
+ /**
+ * Boolean indicating if the video is currently recording or not.
+ */
+ isRecording: boolean;
+}
+
+/**
+ * Handle used to manage the fast movements warning displayed on the screen to the user.
+ */
+export interface FastMovementsDetectionHandle {
+ /**
+ * Event listener for DeviceOrientationEvents.
+ */
+ onDeviceOrientationEvent: (event: DeviceOrientationEvent) => void;
+ /**
+ * The type of fast movements warning that should be displayed to the user. If this value is null, no warning should
+ * be displayed.
+ */
+ fastMovementsWarning: FastMovementType | null;
+ /**
+ * Callback called when the user dismisses the currently displayed fast movements warning.
+ */
+ onWarningDismiss: () => void;
+}
+
+/**
+ * Custom hook used to display warnings to the user when they walk too fast around the car or shake their phone too
+ * much.
+ */
+export function useFastMovementsDetection({
+ isRecording,
+ enableFastWalkingWarning,
+ enablePhoneShakingWarning,
+ fastWalkingWarningCooldown,
+ phoneShakingWarningCooldown,
+}: UseFastMovementsDetectionParams): FastMovementsDetectionHandle {
+ const [fastMovementsWarning, setFastMovementsWarning] = useState(null);
+ const lastRotation = useRef({ alpha: 0, beta: 0, gamma: 0 });
+ const warningTimestamps = useRef>({
+ [FastMovementType.WALKING_TOO_FAST]: 0,
+ [FastMovementType.PHONE_SHAKING]: 0,
+ });
+
+ const isWarningEnabled = useCallback(
+ (type: FastMovementType) => {
+ switch (type) {
+ case FastMovementType.WALKING_TOO_FAST:
+ return enableFastWalkingWarning;
+ case FastMovementType.PHONE_SHAKING:
+ return enablePhoneShakingWarning;
+ default:
+ return false;
+ }
+ },
+ [enableFastWalkingWarning, enablePhoneShakingWarning],
+ );
+
+ const getWarningCooldown = useCallback(
+ (type: FastMovementType) => {
+ switch (type) {
+ case FastMovementType.WALKING_TOO_FAST:
+ return fastWalkingWarningCooldown;
+ case FastMovementType.PHONE_SHAKING:
+ return phoneShakingWarningCooldown;
+ default:
+ return Infinity;
+ }
+ },
+ [fastWalkingWarningCooldown, phoneShakingWarningCooldown],
+ );
+
+ const onDeviceOrientationEvent = useCallback(
+ (event: DeviceOrientationEvent) => {
+ const alpha = event.alpha ?? 0;
+ const beta = event.beta ?? 0;
+ const gamma = event.gamma ?? 0;
+ if (isRecording) {
+ const now = Date.now();
+ const type = detectFastMovements({ alpha, beta, gamma }, lastRotation.current);
+
+ if (
+ type &&
+ isWarningEnabled(type) &&
+ Date.now() - warningTimestamps.current[type] > getWarningCooldown(type)
+ ) {
+ setFastMovementsWarning(type);
+ warningTimestamps.current[type] = now;
+ }
+ }
+ lastRotation.current = { alpha, beta, gamma };
+ },
+ [isRecording, isWarningEnabled, getWarningCooldown],
+ );
+
+ const onWarningDismiss = useCallback(() => setFastMovementsWarning(null), []);
+
+ return useObjectMemo({
+ onDeviceOrientationEvent,
+ fastMovementsWarning,
+ onWarningDismiss,
+ });
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/index.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/index.ts
new file mode 100644
index 000000000..168068495
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/index.ts
@@ -0,0 +1 @@
+export * from './useFrameSelection';
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/laplaceScores.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/laplaceScores.ts
new file mode 100644
index 000000000..272e17506
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/laplaceScores.ts
@@ -0,0 +1,68 @@
+/* eslint-disable no-param-reassign */
+
+export interface LaplaceScores {
+ mean: number;
+ std: number;
+}
+
+/**
+ * This function calculates the Laplace Scores for a given pixel array. This score can be used to get a rough estimate
+ * of the blurriness of the picture.
+ *
+ * Picture A is less blurry than picture B if :
+ * calculateLaplaceScores(A).std > calculateLaplaceScores(B).std
+ *
+ * ***WARNING : To save up memory space, the pixels of the array are modified in place!! Before using this function, be
+ * sure to make a copy of the array using the `array.slice()` method (you might have performance issues if you use any
+ * other method for the duplication of the array).***
+ */
+export function calculateLaplaceScores(
+ pixels: Uint8ClampedArray,
+ width: number,
+ height: number,
+): LaplaceScores {
+ for (let i = 0; i < pixels.length; i += 4) {
+ pixels[i] = 127;
+ pixels[i + 2] = 0;
+ }
+ const kernel = [
+ [0, 1, 0],
+ [1, -4, 1],
+ [0, 1, 0],
+ ];
+ const squareSize = Math.round((0.8 * Math.min(height, width)) / 2) * 2;
+ const yMin = (height - squareSize) / 2;
+ const xMin = (width - squareSize) / 2;
+ for (let y = yMin + 1; y < yMin + squareSize - 1; y++) {
+ for (let x = xMin + 1; x < xMin + squareSize - 1; x++) {
+ let sum = 127;
+ const i = (y * width + x) * 4;
+ for (let ky = -1; ky <= 1; ky++) {
+ for (let kx = -1; kx <= 1; kx++) {
+ const neighborIndex = ((y + ky) * width + (x + kx)) * 4;
+ const neighborGreen = pixels[neighborIndex + 1];
+ sum += kernel[ky + 1][kx + 1] * neighborGreen;
+ }
+ }
+ pixels[i] = sum;
+ }
+ }
+ let laplaceSum = 0;
+ for (let y = yMin + 1; y < yMin + squareSize - 1; y++) {
+ for (let x = xMin + 1; x < xMin + squareSize - 1; x++) {
+ const i = (y * width + x) * 4;
+ laplaceSum += pixels[i];
+ }
+ }
+ const laplaceMean = laplaceSum / ((squareSize - 2) * (squareSize - 2));
+ let se = 0;
+ for (let y = yMin + 1; y < yMin + squareSize - 1; y++) {
+ for (let x = xMin + 1; x < xMin + squareSize - 1; x++) {
+ const i = (y * width + x) * 4;
+ const diff = pixels[i] - laplaceMean;
+ se += diff * diff;
+ }
+ }
+ const laplaceStd = Math.sqrt(se / ((squareSize - 2) * (squareSize - 2)));
+ return { mean: laplaceMean, std: laplaceStd };
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/useFrameSelection.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/useFrameSelection.ts
new file mode 100644
index 000000000..0df129a12
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/useFrameSelection.ts
@@ -0,0 +1,94 @@
+import { useCallback, useRef } from 'react';
+import { MonkPicture } from '@monkvision/types';
+import { useInterval, useObjectMemo, useQueue } from '@monkvision/common';
+import { CameraHandle } from '@monkvision/camera-web';
+import { useMonitoring } from '@monkvision/monitoring';
+import { calculateLaplaceScores } from './laplaceScores';
+
+/**
+ * Params accepted by the useFrameSelection hook.
+ */
+export interface UseFrameSelectionParams {
+ /**
+ * The camera handle.
+ */
+ handle: CameraHandle;
+ /**
+ * Interval (in milliseconds) at which camera frames should be taken.
+ */
+ frameSelectionInterval: number;
+ /**
+ * Callback called when a frame has been selected and should be uploaded to the API.
+ */
+ onFrameSelected?: (picture: MonkPicture) => void;
+}
+
+/**
+ * Handle used to manage the frame selection feature.
+ */
+export interface FrameSelectionHandle {
+ /**
+ * The number of frames that have successfully been processed and added to the upload queue.
+ */
+ processedFrames: number;
+ /**
+ * The total number of frames added to the processing queue.
+ */
+ totalProcessingFrames: number;
+ /**
+ * Callback called when a video frame should be captured.
+ */
+ onCaptureVideoFrame: () => void;
+}
+
+/**
+ * Custom hook used to manage the video frame selection. Basically, every time a camera screenshot is taken, it is added
+ * to the frame selection processing queue. The blurriness score of the screenshot is calculated, and the best video
+ * frame (the less blurry one) is always stored in memory. Finally, every `frameSelectionInterval` milliseconds, the
+ * best video frame is "selected" (to be uploaded to the API) and the process resets.
+ */
+export function useFrameSelection({
+ handle,
+ frameSelectionInterval,
+ onFrameSelected,
+}: UseFrameSelectionParams): FrameSelectionHandle {
+ const bestScore = useRef(null);
+ const bestFrame = useRef(null);
+ const { handleError } = useMonitoring();
+
+ const processingQueue = useQueue(
+ (image: ImageData) =>
+ new Promise((resolve) => {
+ // Note : Other array-copying methods might result in performance issues
+ const imagePixelsCopy = image.data.slice();
+ const laplaceScores = calculateLaplaceScores(imagePixelsCopy, image.width, image.height);
+ if (bestScore.current === null || laplaceScores.std > bestScore.current) {
+ bestScore.current = laplaceScores.std;
+ bestFrame.current = image;
+ }
+ resolve();
+ }),
+ { storeFailedItems: false },
+ );
+
+ const onCaptureVideoFrame = useCallback(() => {
+ processingQueue.push(handle.getImageData());
+ }, [processingQueue.push]);
+
+ useInterval(() => {
+ if (bestFrame.current !== null) {
+ handle
+ .compressImage(bestFrame.current)
+ .then((picture) => onFrameSelected?.(picture))
+ .catch(handleError);
+ }
+ bestScore.current = null;
+ bestFrame.current = null;
+ }, frameSelectionInterval);
+
+ return useObjectMemo({
+ processedFrames: processingQueue.totalItems - processingQueue.processingCount,
+ totalProcessingFrames: processingQueue.totalItems,
+ onCaptureVideoFrame,
+ });
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts
new file mode 100644
index 000000000..7fed6fd0d
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts
@@ -0,0 +1,65 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useObjectMemo } from '@monkvision/common';
+
+/**
+ * Params passed to the useVehicleWalkaround hook.
+ */
+export interface UseVehicleWalkaroundParams {
+ /**
+ * The alpha value of the device orientation.
+ */
+ alpha: number;
+}
+
+/**
+ * Handle returned by the useVehicleWalkaround hook to manage the VehicleWalkaround feature.
+ */
+export interface VehicleWalkaroundHandle {
+ /**
+ * Callback called at the start of the recording, to set the initial alpha position of the user.
+ */
+ startWalkaround: () => void;
+ /**
+ * The current position of the user around the vehicle (between 0 and 360).
+ */
+ walkaroundPosition: number;
+}
+
+/**
+ * Custom hook used to manage the vehicle walkaround tracking.
+ */
+export function useVehicleWalkaround({
+ alpha,
+}: UseVehicleWalkaroundParams): VehicleWalkaroundHandle {
+ const [startingAlpha, setStartingAlpha] = useState(null);
+ const [checkpoint, setCheckpoint] = useState(45);
+ const [nextCheckpoint, setNextCheckpoint] = useState(90);
+
+ const walkaroundPosition = useMemo(() => {
+ if (!startingAlpha) {
+ return 0;
+ }
+ const diff = startingAlpha - alpha;
+ const position = diff < 0 ? 360 + diff : diff;
+ const newWalkaroundPosition = position <= nextCheckpoint ? position : 0;
+ if (nextCheckpoint === 405 && newWalkaroundPosition < 180) {
+ return 359;
+ }
+ return newWalkaroundPosition;
+ }, [startingAlpha, alpha, nextCheckpoint]);
+
+ const startWalkaround = useCallback(() => {
+ setStartingAlpha(alpha);
+ setCheckpoint(45);
+ setNextCheckpoint(90);
+ }, [alpha]);
+
+ useEffect(() => {
+ if (walkaroundPosition >= checkpoint) {
+ setCheckpoint(nextCheckpoint);
+ setNextCheckpoint((value) => value + 45);
+ }
+ }, [walkaroundPosition, checkpoint, nextCheckpoint]);
+
+ return useObjectMemo({ startWalkaround, walkaroundPosition });
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts
new file mode 100644
index 000000000..f139eaf16
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts
@@ -0,0 +1,241 @@
+import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react';
+import { useInterval } from '@monkvision/common';
+import { VideoCaptureAppConfig } from '@monkvision/types';
+import { VehicleWalkaroundHandle } from './useVehicleWalkaround';
+import { useEnforceOrientation } from '../../hooks';
+
+/**
+ * Enumeration of the different tooltips displayed on top of the recording button during the recording process.
+ */
+export enum VideoRecordingTooltip {
+ /**
+ * Tooltip displayed before the recording has been started, to indicate to the user where to press to start the
+ * recording.
+ */
+ START = 'start',
+ /**
+ * Tooltip displayed at the end of the recording, to indicate to the user where to press to stop the recording.
+ */
+ END = 'end',
+}
+
+/**
+ * Params accepted by the useVideoRecording hook.
+ */
+export interface UseVideoRecordingParams
+ extends Pick,
+ Pick {
+ /**
+ * Boolean indicating if the video is currently recording or not.
+ */
+ isRecording: boolean;
+ /**
+ * Callback called when setting the `isRecording` state.
+ */
+ setIsRecording: Dispatch>;
+ /**
+ * The interval in milliseconds at which screenshots of the video stream should be taken.
+ */
+ screenshotInterval: number;
+ /**
+ * The minimum duration of a recording.
+ *
+ * If the user tries to stop the recording too soon, the recording will be paused, and a warning dialog will be
+ * displayed on the screen, asking the user if they want to restart the recording over, or resume recording the
+ * vehicle walkaround.
+ */
+ minRecordingDuration: number;
+ /**
+ * Callback called when a screenshot of the video stream should be taken and then added to the processing queue.
+ */
+ onCaptureVideoFrame?: () => void;
+ /**
+ * Callback called when the recording is complete.
+ */
+ onRecordingComplete?: () => void;
+}
+
+/**
+ * Handle returned by the useVideoRecording hook used to maange the video recording (AKA : The process of taking
+ * screenshots of the video stream at a given interval).
+ */
+export interface VideoRecordingHandle {
+ /**
+ * Boolean indicating if the video recording is paused or not.
+ */
+ isRecordingPaused: boolean;
+ /**
+ * The total duration (in milliseconds) of the current video recording.
+ */
+ recordingDurationMs: number;
+ /**
+ * Callback called when the user clicks on the record video button.
+ */
+ onClickRecordVideo: () => void;
+ /**
+ * Boolean indicating if the discard video dialog should be displayed on the screen or not.
+ */
+ isDiscardDialogDisplayed: boolean;
+ /**
+ * Callback called when the user clicks on the "Keep Recording" option of the discard video dialog.
+ */
+ onDiscardDialogKeepRecording: () => void;
+ /**
+ * Callback called when the user clicks on the "Discard Video" option of the discard video dialog.
+ */
+ onDiscardDialogDiscardVideo: () => void;
+ /**
+ * Callback called to pause the video recording.
+ */
+ pauseRecording: () => void;
+ /**
+ * Callback called to resume the video recording after it has been paused.
+ */
+ resumeRecording: () => void;
+ /**
+ * The tooltip displayed to the user.
+ */
+ tooltip: VideoRecordingTooltip | null;
+}
+
+const MINIMUM_VEHICLE_WALKAROUND_POSITION = 270;
+
+/**
+ * Custom hook used to manage the video recording (AKA : The process of taking screenshots of the video stream at a
+ * given interval).
+ */
+export function useVideoRecording({
+ isRecording,
+ setIsRecording,
+ screenshotInterval,
+ minRecordingDuration,
+ enforceOrientation,
+ walkaroundPosition,
+ startWalkaround,
+ onCaptureVideoFrame,
+ onRecordingComplete,
+}: UseVideoRecordingParams): VideoRecordingHandle {
+ const [isRecordingPaused, setIsRecordingPaused] = useState(false);
+ const [additionalRecordingDuration, setAdditionalRecordingDuration] = useState(0);
+ const [recordingStartTimestamp, setRecordingStartTimestamp] = useState(null);
+ const [isDiscardDialogDisplayed, setDiscardDialogDisplayed] = useState(false);
+ const [orientationPause, setOrientationPause] = useState(false);
+ const [tooltip, setTooltip] = useState(VideoRecordingTooltip.START);
+ const isViolatingEnforcedOrientation = useEnforceOrientation(enforceOrientation);
+
+ const getRecordingDurationMs = useCallback(
+ () =>
+ additionalRecordingDuration +
+ (recordingStartTimestamp ? Date.now() - recordingStartTimestamp : 0),
+ [additionalRecordingDuration, recordingStartTimestamp],
+ );
+
+ const pauseRecording = useCallback(() => {
+ setIsRecordingPaused((isRecordingPausedValue) => {
+ if (!isRecordingPausedValue) {
+ setIsRecording(false);
+ setRecordingStartTimestamp((recordingStartTimestampValue) => {
+ setAdditionalRecordingDuration((value) =>
+ recordingStartTimestampValue
+ ? value + Date.now() - recordingStartTimestampValue
+ : value,
+ );
+ return null;
+ });
+ }
+ return true;
+ });
+ }, []);
+
+ const resumeRecording = useCallback(() => {
+ setIsRecordingPaused((isRecordingPausedValue) => {
+ if (isRecordingPausedValue) {
+ setRecordingStartTimestamp(Date.now());
+ setIsRecording(true);
+ }
+ return false;
+ });
+ }, []);
+
+ const onClickRecordVideo = useCallback(() => {
+ if (isRecording) {
+ if (
+ getRecordingDurationMs() < minRecordingDuration ||
+ walkaroundPosition < MINIMUM_VEHICLE_WALKAROUND_POSITION
+ ) {
+ pauseRecording();
+ setDiscardDialogDisplayed(true);
+ } else {
+ setIsRecording(false);
+ onRecordingComplete?.();
+ }
+ } else {
+ setAdditionalRecordingDuration(0);
+ setRecordingStartTimestamp(Date.now());
+ setIsRecording(true);
+ startWalkaround();
+ setTooltip(null);
+ }
+ }, [
+ isRecording,
+ getRecordingDurationMs,
+ minRecordingDuration,
+ walkaroundPosition,
+ pauseRecording,
+ onRecordingComplete,
+ ]);
+
+ const onDiscardDialogKeepRecording = useCallback(() => {
+ resumeRecording();
+ setDiscardDialogDisplayed(false);
+ }, [resumeRecording]);
+
+ const onDiscardDialogDiscardVideo = useCallback(() => {
+ setIsRecordingPaused(false);
+ setAdditionalRecordingDuration(0);
+ setRecordingStartTimestamp(null);
+ setIsRecording(false);
+ setDiscardDialogDisplayed(false);
+ }, []);
+
+ useInterval(
+ () => {
+ if (isRecording) {
+ onCaptureVideoFrame?.();
+ }
+ },
+ isRecording ? screenshotInterval : null,
+ );
+
+ useEffect(() => {
+ if (isViolatingEnforcedOrientation && isRecording) {
+ setOrientationPause(true);
+ pauseRecording();
+ } else if (!isViolatingEnforcedOrientation && orientationPause) {
+ setOrientationPause(false);
+ resumeRecording();
+ }
+ }, [isViolatingEnforcedOrientation, isRecording, orientationPause]);
+
+ useEffect(() => {
+ if (isRecording) {
+ if (walkaroundPosition > 315) {
+ setTooltip(VideoRecordingTooltip.END);
+ } else {
+ setTooltip(null);
+ }
+ }
+ }, [walkaroundPosition, isRecording]);
+
+ return {
+ isRecordingPaused,
+ recordingDurationMs: getRecordingDurationMs(),
+ onClickRecordVideo,
+ onDiscardDialogKeepRecording,
+ onDiscardDialogDiscardVideo,
+ isDiscardDialogDisplayed,
+ pauseRecording,
+ resumeRecording,
+ tooltip,
+ };
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoUploadQueue.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoUploadQueue.ts
new file mode 100644
index 000000000..82f0532b8
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoUploadQueue.ts
@@ -0,0 +1,104 @@
+/* eslint-disable no-param-reassign */
+
+import { useObjectMemo, useQueue } from '@monkvision/common';
+import { MonkPicture } from '@monkvision/types';
+import { ImageUploadType, MonkApiConfig, useMonkApi } from '@monkvision/network';
+import { useCallback, useRef } from 'react';
+
+interface VideoFrameUpload {
+ picture: MonkPicture;
+ frameIndex: number;
+ timestamp: number;
+ retryCount: number;
+}
+
+/**
+ * Params accepted by the useVideoUploadQueue hook.
+ */
+export interface VideoUploadQueueParams {
+ /**
+ * The config used to communicate with the API.
+ */
+ apiConfig: MonkApiConfig;
+ /**
+ * The ID of the current inspection.
+ */
+ inspectionId: string;
+ /**
+ * The maximum number of retries allowed for failed image uploads.
+ */
+ maxRetryCount: number;
+}
+
+/**
+ * Handle used to manage the video frame upload queue.
+ */
+export interface VideoUploadQueueHandle {
+ /**
+ * The number of frames that have successfully been uploaded to the API.
+ */
+ uploadedFrames: number;
+ /**
+ * The total number of frames added to the uploading queue.
+ */
+ totalUploadingFrames: number;
+ /**
+ * Callback called when a frame has been selected by the frame selection hook.
+ */
+ onFrameSelected: (picture: MonkPicture) => void;
+}
+
+/**
+ * Hook used to manage the video frame upload queue.
+ */
+export function useVideoUploadQueue({
+ apiConfig,
+ inspectionId,
+ maxRetryCount,
+}: VideoUploadQueueParams): VideoUploadQueueHandle {
+ const frameIndex = useRef(0);
+ const frameTimestamp = useRef(null);
+ const { addImage } = useMonkApi(apiConfig);
+
+ const queue = useQueue(
+ (upload: VideoFrameUpload) =>
+ addImage({
+ uploadType: ImageUploadType.VIDEO_FRAME,
+ inspectionId,
+ picture: upload.picture,
+ frameIndex: upload.frameIndex,
+ timestamp: upload.timestamp,
+ }),
+ {
+ storeFailedItems: true,
+ onItemFail: (upload: VideoFrameUpload) => {
+ upload.retryCount += 1;
+ if (upload.retryCount <= maxRetryCount) {
+ queue.push(upload);
+ }
+ },
+ },
+ );
+
+ const onFrameSelected = useCallback(
+ (picture: MonkPicture) => {
+ const now = Date.now();
+ const upload: VideoFrameUpload = {
+ retryCount: 0,
+ picture,
+ frameIndex: frameIndex.current,
+ timestamp: frameTimestamp.current === null ? 0 : now - frameTimestamp.current,
+ };
+ queue.push(upload);
+ frameIndex.current += 1;
+ frameTimestamp.current = now;
+ },
+ [queue.push],
+ );
+
+ return useObjectMemo({
+ uploadedFrames: queue.totalItems - queue.processingCount,
+ totalUploadingFrames: queue.totalItems,
+ onFrameSelected,
+ });
+}
diff --git a/packages/inspection-capture-web/src/VideoCapture/index.ts b/packages/inspection-capture-web/src/VideoCapture/index.ts
new file mode 100644
index 000000000..b02d91261
--- /dev/null
+++ b/packages/inspection-capture-web/src/VideoCapture/index.ts
@@ -0,0 +1,2 @@
+export { type VideoCaptureProps } from './VideoCapture';
+export { VideoCaptureHOC as VideoCapture } from './VideoCaptureHOC';
diff --git a/packages/inspection-capture-web/src/assets/logos.asset.ts b/packages/inspection-capture-web/src/assets/logos.asset.ts
new file mode 100644
index 000000000..f7d83746d
--- /dev/null
+++ b/packages/inspection-capture-web/src/assets/logos.asset.ts
@@ -0,0 +1,2 @@
+export const monkLogoSVG =
+ '';
diff --git a/packages/inspection-capture-web/src/components/OrientationEnforcer/OrientationEnforcer.styles.ts b/packages/inspection-capture-web/src/components/OrientationEnforcer/OrientationEnforcer.styles.ts
new file mode 100644
index 000000000..6b765e97a
--- /dev/null
+++ b/packages/inspection-capture-web/src/components/OrientationEnforcer/OrientationEnforcer.styles.ts
@@ -0,0 +1,41 @@
+import { Styles } from '@monkvision/types';
+import { useMonkTheme } from '@monkvision/common';
+
+export const styles: Styles = {
+ container: {
+ position: 'fixed',
+ inset: 0,
+ zIndex: 999999,
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ flexDirection: 'column',
+ boxSizing: 'border-box',
+ padding: '50px 10%',
+ },
+ titleContainer: {
+ display: 'flex',
+ alignItems: 'center',
+ },
+ title: {
+ fontSize: 18,
+ marginLeft: 16,
+ },
+ description: {
+ fontSize: 16,
+ paddingTop: 16,
+ opacity: 0.8,
+ textAlign: 'center',
+ },
+};
+
+export function useOrientationEnforcerStyles() {
+ const { palette } = useMonkTheme();
+
+ return {
+ containerStyle: {
+ ...styles['container'],
+ backgroundColor: palette.background.base,
+ },
+ };
+}
diff --git a/packages/inspection-capture-web/src/components/OrientationEnforcer/OrientationEnforcer.tsx b/packages/inspection-capture-web/src/components/OrientationEnforcer/OrientationEnforcer.tsx
new file mode 100644
index 000000000..f17718de5
--- /dev/null
+++ b/packages/inspection-capture-web/src/components/OrientationEnforcer/OrientationEnforcer.tsx
@@ -0,0 +1,39 @@
+import { DeviceOrientation } from '@monkvision/types';
+import { Icon } from '@monkvision/common-ui-web';
+import { useTranslation } from 'react-i18next';
+import { styles, useOrientationEnforcerStyles } from './OrientationEnforcer.styles';
+import { useEnforceOrientation } from '../../hooks';
+
+/**
+ * Props accepted by the OrientationEnforcer component.
+ */
+export interface OrientationEnforcerProps {
+ /**
+ * The device orientation to enforce.
+ */
+ orientation?: DeviceOrientation;
+}
+
+/**
+ * Component that enforces a certain device orientation. If the current device orientation is not equal to the one
+ * passed as a prop, it will display an error on the screen asking the user to rotate their device.
+ */
+export function OrientationEnforcer({ orientation }: OrientationEnforcerProps) {
+ const { t } = useTranslation();
+ const { containerStyle } = useOrientationEnforcerStyles();
+ const isViolatingEnforcedOrientation = useEnforceOrientation(orientation);
+
+ if (!isViolatingEnforcedOrientation) {
+ return null;
+ }
+
+ return (
+
+
+
+
{t('orientationEnforcer.title')}
+
+
{t('orientationEnforcer.description')}
+
+ );
+}
diff --git a/packages/inspection-capture-web/src/components/OrientationEnforcer/index.ts b/packages/inspection-capture-web/src/components/OrientationEnforcer/index.ts
new file mode 100644
index 000000000..f7a059877
--- /dev/null
+++ b/packages/inspection-capture-web/src/components/OrientationEnforcer/index.ts
@@ -0,0 +1 @@
+export { OrientationEnforcer, type OrientationEnforcerProps } from './OrientationEnforcer';
diff --git a/packages/inspection-capture-web/src/components/index.ts b/packages/inspection-capture-web/src/components/index.ts
new file mode 100644
index 000000000..4c205359f
--- /dev/null
+++ b/packages/inspection-capture-web/src/components/index.ts
@@ -0,0 +1 @@
+export * from './OrientationEnforcer';
diff --git a/packages/inspection-capture-web/src/hooks/index.ts b/packages/inspection-capture-web/src/hooks/index.ts
new file mode 100644
index 000000000..9be9ee168
--- /dev/null
+++ b/packages/inspection-capture-web/src/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useStartTasksOnComplete';
+export * from './useEnforceOrientation';
diff --git a/packages/inspection-capture-web/src/hooks/useEnforceOrientation.ts b/packages/inspection-capture-web/src/hooks/useEnforceOrientation.ts
new file mode 100644
index 000000000..19d547cad
--- /dev/null
+++ b/packages/inspection-capture-web/src/hooks/useEnforceOrientation.ts
@@ -0,0 +1,11 @@
+import { useWindowDimensions } from '@monkvision/common';
+import { DeviceOrientation } from '@monkvision/types';
+
+/**
+ * Custom hook used to check if the current device's orientation is violating the enforced orientation. Returns true if
+ * an orientation is being enforced and is not matching the current device orientation.
+ */
+export function useEnforceOrientation(orientation?: DeviceOrientation | null): boolean {
+ const dimensions = useWindowDimensions();
+ return !!orientation && (orientation === DeviceOrientation.PORTRAIT) !== dimensions.isPortrait;
+}
diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts b/packages/inspection-capture-web/src/hooks/useStartTasksOnComplete.ts
similarity index 87%
rename from packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts
rename to packages/inspection-capture-web/src/hooks/useStartTasksOnComplete.ts
index fe1a2039c..cfc33ba13 100644
--- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts
+++ b/packages/inspection-capture-web/src/hooks/useStartTasksOnComplete.ts
@@ -1,4 +1,4 @@
-import { CaptureAppConfig, Sight, TaskName } from '@monkvision/types';
+import { PhotoCaptureAppConfig, Sight, TaskName } from '@monkvision/types';
import { flatMap, LoadingState, uniq } from '@monkvision/common';
import { MonkApiConfig, useMonkApi } from '@monkvision/network';
import { useMonitoring } from '@monkvision/monitoring';
@@ -8,15 +8,11 @@ import { useCallback } from 'react';
* Parameters of the useStartTasksOnComplete hook.
*/
export interface UseStartTasksOnCompleteParams
- extends Pick {
+ extends Pick {
/**
* The inspection ID.
*/
inspectionId: string;
- /**
- * The list of sights passed to the PhotoCapture component.
- */
- sights: Sight[];
/**
* The api config used to communicate with the API.
*/
@@ -25,6 +21,10 @@ export interface UseStartTasksOnCompleteParams
* Global loading state of the PhotoCapture component.
*/
loading: LoadingState;
+ /**
+ * The list of sights passed to the PhotoCapture component.
+ */
+ sights?: Sight[];
}
/**
@@ -47,7 +47,9 @@ function getTasksToStart({
if (Array.isArray(startTasksOnComplete)) {
tasks = startTasksOnComplete;
} else {
- tasks = uniq(flatMap(sights, (sight) => tasksBySight?.[sight.id] ?? sight.tasks));
+ tasks = sights
+ ? uniq(flatMap(sights, (sight) => tasksBySight?.[sight.id] ?? sight.tasks))
+ : [TaskName.DAMAGE_DETECTION];
additionalTasks?.forEach((additionalTask) => {
if (!tasks.includes(additionalTask)) {
tasks.push(additionalTask);
diff --git a/packages/inspection-capture-web/src/index.ts b/packages/inspection-capture-web/src/index.ts
index 462180cd3..7d3d1a8df 100644
--- a/packages/inspection-capture-web/src/index.ts
+++ b/packages/inspection-capture-web/src/index.ts
@@ -1,2 +1,3 @@
export * from './PhotoCapture';
+export * from './VideoCapture';
export * from './i18n';
diff --git a/packages/inspection-capture-web/src/translations/de.json b/packages/inspection-capture-web/src/translations/de.json
index c28a7e6be..fe0376ebc 100644
--- a/packages/inspection-capture-web/src/translations/de.json
+++ b/packages/inspection-capture-web/src/translations/de.json
@@ -1,9 +1,5 @@
{
"photo": {
- "orientationError": {
- "title": "Bitte drehen Sie Ihr Gerät.",
- "description": "Möglicherweise müssen Sie die Ausrichtung Ihres Geräts über die Telefoneinstellungen entsperren."
- },
"badConnectionWarning": {
"message": "Es scheint, dass Ihre Verbindung instabil ist. Das Hochladen von Bildern kann lange dauern oder unmöglich sein.",
"confirm": "Ich verstehe"
@@ -44,5 +40,63 @@
"next": "Weiter"
}
}
+ },
+ "video": {
+ "introduction": {
+ "title": "Aufzeichnung eines Videos zur Fahrzeugbegehung"
+ },
+ "permissions": {
+ "camera": {
+ "title": "Kamera",
+ "description": "Um Videos aufzunehmen, müssen Sie den Zugriff auf die Kamera des Geräts erlauben"
+ },
+ "compass": {
+ "title": "Kompass",
+ "description": "Um einen vollständigen 360°-Umlauf des Fahrzeugs zu erfassen, müssen Sie den Zugriff auf den Kompass des Geräts erlauben"
+ },
+ "confirm": "Berechtigungen verwalten"
+ },
+ "tutorial": {
+ "start": {
+ "title": "Beginnen Sie an der Vorderseite",
+ "description": "Halten Sie einen Abstand von 1 Meter und gehen Sie langsam um das Fahrzeug herum, indem Sie es von der Dachlinie bis zum Boden aufnehmen."
+ },
+ "finish": {
+ "title": "Beenden Sie die Aufnahme dort, wo Sie begonnen haben.",
+ "description": "Sie sollten die Aufnahme in etwa 45 Sekunden beenden."
+ },
+ "photos": {
+ "title": "Machen Sie nach Bedarf Fotos",
+ "description": "Drücken Sie den Auslöser, um während der Aufnahme Fotos von der Wiedervermarktung oder von Schäden zu machen."
+ },
+ "confirm": "Ein Video aufnehmen"
+ },
+ "recording": {
+ "discardDialog": {
+ "message": "Möchten Sie das Video verwerfen? Sie sind noch nicht ganz um das Fahrzeug herumgekommen.",
+ "keepRecording": "Aufnahme beibehalten",
+ "discardVideo": "Video verwerfen"
+ },
+ "fastMovementsDialog": {
+ "walkingTooFast": "Sie sind zu schnell unterwegs! Fahren Sie etwas langsamer, es sollte etwa eine Minute dauern, bis Sie das Fahrzeug umrundet haben.",
+ "phoneShaking": "Ihr Gerät wackelt zu stark! Versuchen Sie, die Kamera ruhig zu halten, während Sie Ihr Fahrzeug aufnehmen.",
+ "confirm": "OK"
+ },
+ "tooltip": {
+ "start": "Sobald Sie sich vor dem Fahrzeug befinden, drücken Sie die Taste, um die Videoaufnahme zu starten.",
+ "end": "Drücken Sie nach Abschluss der Fahrzeugumrundung die Taste, um die Aufnahme zu beenden."
+ }
+ },
+ "processing": {
+ "processing": "Bearbeitung des Videos...",
+ "uploading": "Hochladen des Videos...",
+ "success": "Videobearbeitung abgeschlossen!",
+ "error": "Beim Abschluss der Inspektion ist ein Fehler aufgetreten. Inspektion ID :",
+ "done": "Erledigt"
+ }
+ },
+ "orientationEnforcer": {
+ "title": "Bitte drehen Sie Ihr Gerät.",
+ "description": "Möglicherweise müssen Sie die Ausrichtung Ihres Geräts über die Telefoneinstellungen entsperren."
}
}
diff --git a/packages/inspection-capture-web/src/translations/en.json b/packages/inspection-capture-web/src/translations/en.json
index 601fcf592..33f7c9bf1 100644
--- a/packages/inspection-capture-web/src/translations/en.json
+++ b/packages/inspection-capture-web/src/translations/en.json
@@ -1,9 +1,5 @@
{
"photo": {
- "orientationError": {
- "title": "Please rotate your device.",
- "description": "You may need to unlock your device orientation through your phone settings."
- },
"badConnectionWarning": {
"message": "It seems like your connection is unstable. Picture uploads might be long or impossible.",
"confirm": "I Understand"
@@ -44,5 +40,63 @@
"next": "Next"
}
}
+ },
+ "video": {
+ "introduction": {
+ "title": "Record a vehicle walkaround video"
+ },
+ "permissions": {
+ "camera": {
+ "title": "Camera",
+ "description": "To record video, you need to allow access to the device's camera"
+ },
+ "compass": {
+ "title": "Compass",
+ "description": "To detect a full 360° circulation of the vehicle, you need to allow access to the device's compass"
+ },
+ "confirm": "Manage Permissions"
+ },
+ "tutorial": {
+ "start": {
+ "title": "Start at the Front",
+ "description": "Keep a 3 to 4-foot distance and walk slowly around the vehicle, capturing from the roofline to the ground."
+ },
+ "finish": {
+ "title": "Finish where you began",
+ "description": "You should be done recording in about 45 seconds."
+ },
+ "photos": {
+ "title": "Take photos as needed",
+ "description": "Press the shutter button to capture remarketing or damage photos while you're recording."
+ },
+ "confirm": "Record a Video"
+ },
+ "recording": {
+ "discardDialog": {
+ "message": "Do you want to discard the video? You haven' t gone all the way around the vehicle.",
+ "keepRecording": "Keep Recording",
+ "discardVideo": "Discard Video"
+ },
+ "fastMovementsDialog": {
+ "walkingTooFast": "You're moving too fast! Slow down a bit, it should take you around one minute to complete the vehicle walkaround.",
+ "phoneShaking": "Your device is shaking too much! Try to keep the camera steady while recording your vehicle.",
+ "confirm": "OK"
+ },
+ "tooltip": {
+ "start": "Once in front of the vehicle, press the button to start recording the video.",
+ "end": "Once the vehicle walkaround is completed, press the button to stop the recording."
+ }
+ },
+ "processing": {
+ "processing": "Processing the video...",
+ "uploading": "Uploading the video...",
+ "success": "Video processing completed!",
+ "error": "An error occurred when finalizing the inspection. Inspection ID :",
+ "done": "Done"
+ }
+ },
+ "orientationEnforcer": {
+ "title": "Please rotate your device.",
+ "description": "You may need to unlock your device orientation through your phone settings."
}
}
diff --git a/packages/inspection-capture-web/src/translations/fr.json b/packages/inspection-capture-web/src/translations/fr.json
index 95e65564f..7208579fc 100644
--- a/packages/inspection-capture-web/src/translations/fr.json
+++ b/packages/inspection-capture-web/src/translations/fr.json
@@ -1,9 +1,5 @@
{
"photo": {
- "orientationError": {
- "title": "Veuillez tourner votre appareil.",
- "description": "Il vous faudra peut-être débloquer l'orientation de votre appareil dans les paramètres de votre téléphone."
- },
"badConnectionWarning": {
"message": "Il semble que votre connexion soit instable. Les téléchargements d'images peuvent être longs ou impossibles.",
"confirm": "J'ai compris"
@@ -44,5 +40,63 @@
"next": "Suivant"
}
}
+ },
+ "video": {
+ "introduction": {
+ "title": "Enregistrer une vidéo d'inspection du véhicule"
+ },
+ "permissions": {
+ "camera": {
+ "title": "Caméra",
+ "description": "Pour enregistrer une vidéo, vous devez autoriser l'accès à la caméra de l'appareil"
+ },
+ "compass": {
+ "title": "Boussole",
+ "description": "Pour détecter une circulation complète à 360° du véhicule, vous devez autoriser l'accès à la boussole de l'appareil"
+ },
+ "confirm": "Gérer les autorisations"
+ },
+ "tutorial": {
+ "start": {
+ "title": "Commencez par l'avant",
+ "description": "Gardez une distance d'un mètre et faites lentement le tour du véhicule, en capturant la ligne de toit jusqu'au sol."
+ },
+ "finish": {
+ "title": "Terminez là où vous avez commencé",
+ "description": "L'enregistrement devrait être terminé au bout d'environ 45 secondes."
+ },
+ "photos": {
+ "title": "Prenez des photos si nécessaire",
+ "description": "Appuyez sur le bouton de capture pour prendre des photos de remarketing ou de dommages pendant l'enregistrement."
+ },
+ "confirm": "Enregistrer une vidéo"
+ },
+ "recording": {
+ "discardDialog": {
+ "message": "Voulez-vous annuler la vidéo ? Vous n'avez pas fait le tour complet du véhicule.",
+ "keepRecording": "Continuer l'enregistrement",
+ "discardVideo": "Annuler la vidéo"
+ },
+ "fastMovementsDialog": {
+ "walkingTooFast": "Vous allez trop vite ! Ralentissez un peu. Il devrait vous falloir environ une minute pour faire le tour du véhicule.",
+ "phoneShaking": "Votre appareil tremble trop ! Essayez de garder l'appareil photo stable pendant l'enregistrement de votre véhicule.",
+ "confirm": "OK"
+ },
+ "tooltip": {
+ "start": "Une fois devant le véhicule, appuyez sur le bouton pour commencer à enregistrer la vidéo.",
+ "end": "Une fois le tour du véhicule terminé, appuyez sur le bouton pour arrêter l'enregistrement."
+ }
+ },
+ "processing": {
+ "processing": "Traitement de la vidéo...",
+ "uploading": "Téléchargement de la vidéo...",
+ "success": "Traitement de la vidéo terminé !",
+ "error": "Une erreur s'est produite lors de la finalisation de l'inspection. ID d'inspection :",
+ "done": "Terminé"
+ }
+ },
+ "orientationEnforcer": {
+ "title": "Veuillez tourner votre appareil.",
+ "description": "Il vous faudra peut-être débloquer l'orientation de votre appareil dans les paramètres de votre téléphone."
}
}
diff --git a/packages/inspection-capture-web/src/translations/nl.json b/packages/inspection-capture-web/src/translations/nl.json
index f593c59b0..27a5bfb34 100644
--- a/packages/inspection-capture-web/src/translations/nl.json
+++ b/packages/inspection-capture-web/src/translations/nl.json
@@ -1,9 +1,5 @@
{
"photo": {
- "orientationError": {
- "title": "Draai uw apparaat om.",
- "description": "U moet mogelijk de oriëntatie van uw apparaat ontgrendelen via de instellingen van uw telefoon."
- },
"badConnectionWarning": {
"message": "Het lijkt erop dat je verbinding instabiel is. Het uploaden van afbeeldingen kan lang duren of onmogelijk zijn.",
"confirm": "Ik begrijp het"
@@ -44,5 +40,63 @@
"next": "Volgende"
}
}
+ },
+ "video": {
+ "introduction": {
+ "title": "Een doorloopvideo van een voertuig opnemen"
+ },
+ "permissions": {
+ "camera": {
+ "title": "Camera",
+ "description": "Om video's op te nemen, moet u toegang verlenen tot de camera van het apparaat"
+ },
+ "compass": {
+ "title": "Kompas",
+ "description": "Om een volledige 360°-omloop van het voertuig te detecteren, moet u toegang tot het kompas van het apparaat toestaan"
+ },
+ "confirm": "Machtigingen beheren"
+ },
+ "tutorial": {
+ "start": {
+ "title": "Begin aan de voorkant",
+ "description": "Houd 1 meter afstand en loop langzaam rond het voertuig, waarbij je vanaf de daklijn naar de grond loopt."
+ },
+ "finish": {
+ "title": "Eindig waar je begon",
+ "description": "Je moet na ongeveer 45 seconden klaar zijn met opnemen."
+ },
+ "photos": {
+ "title": "Maak foto's als dat nodig is",
+ "description": "Druk op de ontspanknop om remarketing- of schadefoto's te maken terwijl je opneemt."
+ },
+ "confirm": "Een video opnemen"
+ },
+ "recording": {
+ "discardDialog": {
+ "message": "Wil je de video weggooien? Je hebt het voertuig nog niet helemaal rondgereden.",
+ "keepRecording": "Opname behouden",
+ "discardVideo": "Video weggooien"
+ },
+ "fastMovementsDialog": {
+ "walkingTooFast": "Je gaat te snel! Doe het wat rustiger aan, het zou ongeveer een minuut moeten duren om de walkaround van het voertuig te voltooien.",
+ "phoneShaking": "Je toestel trilt te veel! Probeer de camera stil te houden terwijl je je voertuig opneemt.",
+ "confirm": "OK"
+ },
+ "tooltip": {
+ "start": "Zodra je voor het voertuig staat, druk je op de knop om de video-opname te starten.",
+ "end": "Zodra de walkaround is voltooid, druk je op de knop om de opname te stoppen."
+ }
+ },
+ "processing": {
+ "processing": "De video verwerken...",
+ "uploading": "Video uploaden...",
+ "success": "Videobewerking voltooid!",
+ "error": "Er is een fout opgetreden bij het afronden van de inspectie. Inspectie ID :",
+ "done": "Gedaan"
+ }
+ },
+ "orientationEnforcer": {
+ "title": "Draai uw apparaat om.",
+ "description": "U moet mogelijk de oriëntatie van uw apparaat ontgrendelen via de instellingen van uw telefoon."
}
}
diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx
index f229b26e5..d47cfb4b8 100644
--- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx
+++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx
@@ -3,9 +3,28 @@ import {
CameraResolution,
ComplianceIssue,
CompressionFormat,
+ DeviceOrientation,
PhotoCaptureTutorialOption,
TaskName,
} from '@monkvision/types';
+import { Camera } from '@monkvision/camera-web';
+import { useI18nSync, useLoadingState, usePreventExit } from '@monkvision/common';
+import { BackdropDialog, InspectionGallery } from '@monkvision/common-ui-web';
+import { useMonitoring } from '@monkvision/monitoring';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { act, render, waitFor } from '@testing-library/react';
+import { PhotoCapture, PhotoCaptureHUD, PhotoCaptureProps } from '../../src';
+import {
+ useAdaptiveCameraConfig,
+ useAddDamageMode,
+ useBadConnectionWarning,
+ usePhotoCaptureImages,
+ usePhotoCaptureSightState,
+ usePhotoCaptureTutorial,
+ usePictureTaken,
+ useUploadQueue,
+} from '../../src/PhotoCapture/hooks';
+import { useStartTasksOnComplete } from '../../src/hooks';
const { PhotoCaptureMode } = jest.requireActual('../../src/PhotoCapture/hooks');
@@ -62,24 +81,9 @@ jest.mock('../../src/PhotoCapture/hooks', () => ({
})),
}));
-import { Camera } from '@monkvision/camera-web';
-import { useI18nSync, useLoadingState, usePreventExit } from '@monkvision/common';
-import { BackdropDialog, InspectionGallery } from '@monkvision/common-ui-web';
-import { useMonitoring } from '@monkvision/monitoring';
-import { expectPropsOnChildMock } from '@monkvision/test-utils';
-import { act, render, waitFor } from '@testing-library/react';
-import { PhotoCapture, PhotoCaptureHUD, PhotoCaptureProps } from '../../src';
-import {
- useAdaptiveCameraConfig,
- useAddDamageMode,
- useBadConnectionWarning,
- usePhotoCaptureImages,
- usePhotoCaptureSightState,
- usePictureTaken,
- useStartTasksOnComplete,
- useUploadQueue,
- usePhotoCaptureTutorial,
-} from '../../src/PhotoCapture/hooks';
+jest.mock('../../src/hooks', () => ({
+ useStartTasksOnComplete: jest.fn(() => jest.fn()),
+}));
function createProps(): PhotoCaptureProps {
return {
@@ -90,7 +94,7 @@ function createProps(): PhotoCaptureProps {
authToken: 'test-auth-token-test',
thumbnailDomain: 'test-thumbnail-domain',
},
-
+ enforceOrientation: DeviceOrientation.PORTRAIT,
additionalTasks: [TaskName.DASHBOARD_OCR],
tasksBySight: { 'test-sight-1': [TaskName.IMAGE_EDITING] },
startTasksOnComplete: [TaskName.COMPLIANCES],
@@ -344,6 +348,7 @@ describe('PhotoCapture component', () => {
onNextTutorialStep: tutorial.goToNextTutorialStep,
onCloseTutorial: tutorial.closeTutorial,
allowSkipTutorial: props.allowSkipRetake,
+ enforceOrientation: props.enforceOrientation,
},
});
diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx
index e79f71425..969e6dc79 100644
--- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx
+++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx
@@ -1,19 +1,4 @@
-import { Image, ImageStatus } from '@monkvision/types';
-
-jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/hooks', () => ({
- ...jest.requireActual('../../../src/PhotoCapture/PhotoCaptureHUD/hooks'),
- useComplianceNotification: jest.fn(() => false),
-}));
-jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons', () => ({
- PhotoCaptureHUDButtons: jest.fn(() => <>>),
-}));
-jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay', () => ({
- PhotoCaptureHUDOverlay: jest.fn(() => <>>),
-}));
-jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements', () => ({
- PhotoCaptureHUDElements: jest.fn(() => <>>),
-}));
-
+import { DeviceOrientation, Image, ImageStatus } from '@monkvision/types';
import { useTranslation } from 'react-i18next';
import { act, render, screen } from '@testing-library/react';
import { sights } from '@monkvision/sights';
@@ -24,11 +9,29 @@ import { BackdropDialog } from '@monkvision/common-ui-web';
import {
PhotoCaptureHUD,
PhotoCaptureHUDButtons,
- PhotoCaptureHUDOverlay,
PhotoCaptureHUDElements,
+ PhotoCaptureHUDOverlay,
PhotoCaptureHUDProps,
} from '../../../src';
import { PhotoCaptureMode } from '../../../src/PhotoCapture/hooks';
+import { OrientationEnforcer } from '../../../src/components';
+
+jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/hooks', () => ({
+ ...jest.requireActual('../../../src/PhotoCapture/PhotoCaptureHUD/hooks'),
+ useComplianceNotification: jest.fn(() => false),
+}));
+jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons', () => ({
+ PhotoCaptureHUDButtons: jest.fn(() => <>>),
+}));
+jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay', () => ({
+ PhotoCaptureHUDOverlay: jest.fn(() => <>>),
+}));
+jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDElements', () => ({
+ PhotoCaptureHUDElements: jest.fn(() => <>>),
+}));
+jest.mock('../../../src/components', () => ({
+ OrientationEnforcer: jest.fn(() => <>>),
+}));
const cameraTestId = 'camera-test-id';
@@ -66,6 +69,7 @@ function createProps(): PhotoCaptureHUDProps {
allowSkipTutorial: false,
onNextTutorialStep: jest.fn(),
onCloseTutorial: jest.fn(),
+ enforceOrientation: DeviceOrientation.PORTRAIT,
};
}
@@ -200,4 +204,13 @@ describe('PhotoCaptureHUD component', () => {
unmount();
});
+
+ it('should pass the enforceOrientation prop to the OrientationEnforcer', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(OrientationEnforcer, { orientation: props.enforceOrientation });
+
+ unmount();
+ });
});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCapture.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCapture.test.tsx
new file mode 100644
index 000000000..6b4256a2f
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCapture.test.tsx
@@ -0,0 +1,231 @@
+const { FastMovementType } = jest.requireActual('../../src/VideoCapture/hooks');
+
+jest.mock('../../src/VideoCapture/hooks', () => ({
+ FastMovementType,
+ useFastMovementsDetection: jest.fn(() => ({
+ onDeviceOrientationEvent: jest.fn(),
+ fastMovementsWarning: FastMovementType.PHONE_SHAKING,
+ onWarningDismiss: jest.fn(),
+ })),
+}));
+jest.mock('../../src/hooks', () => ({
+ useStartTasksOnComplete: jest.fn(() => jest.fn(() => Promise.resolve())),
+}));
+jest.mock('../../src/VideoCapture/VideoCapturePermissions', () => ({
+ VideoCapturePermissions: jest.fn(() => <>>),
+}));
+jest.mock('../../src/VideoCapture/VideoCaptureHUD', () => ({
+ VideoCaptureHUD: jest.fn(() => <>>),
+}));
+
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { useDeviceOrientation } from '@monkvision/common';
+import { DeviceOrientation, TaskName } from '@monkvision/types';
+import { act, render, waitFor } from '@testing-library/react';
+import { Camera } from '@monkvision/camera-web';
+import { VideoCapture, VideoCaptureProps } from '../../src';
+import { useFastMovementsDetection } from '../../src/VideoCapture/hooks';
+import { useStartTasksOnComplete } from '../../src/hooks';
+import { VideoCapturePermissions } from '../../src/VideoCapture/VideoCapturePermissions';
+import { VideoCaptureHUD } from '../../src/VideoCapture/VideoCaptureHUD';
+
+function createProps(): VideoCaptureProps {
+ return {
+ inspectionId: 'test-inspection-id',
+ apiConfig: {
+ apiDomain: 'test-api-domain',
+ authToken: 'test-auth-token',
+ thumbnailDomain: 'test-thumbnail-domain',
+ },
+ additionalTasks: [TaskName.INSPECTION_PDF],
+ startTasksOnComplete: [TaskName.HUMAN_IN_THE_LOOP],
+ enforceOrientation: DeviceOrientation.LANDSCAPE,
+ minRecordingDuration: 1234,
+ maxRetryCount: 13,
+ enableFastWalkingWarning: true,
+ enablePhoneShakingWarning: false,
+ fastWalkingWarningCooldown: 4321,
+ phoneShakingWarningCooldown: 6543,
+ onComplete: jest.fn(),
+ lang: 'us',
+ };
+}
+
+describe('VideoCapture component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should pass the proper params to the useFastMovementsDetection hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(useFastMovementsDetection).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isRecording: false,
+ enableFastWalkingWarning: props.enableFastWalkingWarning,
+ enablePhoneShakingWarning: props.enablePhoneShakingWarning,
+ fastWalkingWarningCooldown: props.fastWalkingWarningCooldown,
+ phoneShakingWarningCooldown: props.phoneShakingWarningCooldown,
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should pass the proper params to the useDeviceOrientation hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onDeviceOrientationEvent } = (useFastMovementsDetection as jest.Mock).mock.results[0]
+ .value;
+ expect(useDeviceOrientation).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onDeviceOrientationEvent,
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should pass the proper params to the useStartTasksOnComplete hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(useStartTasksOnComplete).toHaveBeenCalledWith(
+ expect.objectContaining({
+ inspectionId: props.inspectionId,
+ apiConfig: props.apiConfig,
+ additionalTasks: props.additionalTasks,
+ startTasksOnComplete: props.startTasksOnComplete,
+ loading: expect.anything(),
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should start by displaying the camera permissions', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(VideoCapturePermissions).toHaveBeenCalled();
+ expect(Camera).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should pass the proper props to the VideoCapturePermissions component', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { requestCompassPermission } = (useDeviceOrientation as jest.Mock).mock.results[0].value;
+ expectPropsOnChildMock(VideoCapturePermissions, {
+ requestCompassPermission,
+ });
+
+ unmount();
+ });
+
+ it('should switch over to the camera once the permissions are granted', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePermissions, {
+ onSuccess: expect.any(Function),
+ });
+ const { onSuccess } = (VideoCapturePermissions as jest.Mock).mock.calls[0][0];
+ expect(Camera).not.toHaveBeenCalled();
+ act(() => {
+ onSuccess();
+ });
+ expect(Camera).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should use the VideoCaptureHUD component as the Camera HUD', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onSuccess } = (VideoCapturePermissions as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onSuccess();
+ });
+ expectPropsOnChildMock(Camera, {
+ HUDComponent: VideoCaptureHUD,
+ });
+
+ unmount();
+ });
+
+ it('should pass the proper props to the VideoCaptureHUD component', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onSuccess } = (VideoCapturePermissions as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onSuccess();
+ });
+
+ const useDeviceOrientationResults = (useDeviceOrientation as jest.Mock).mock.results;
+ const { alpha } = useDeviceOrientationResults[useDeviceOrientationResults.length - 1].value;
+
+ const useFastMovementsDetectionResults = (useFastMovementsDetection as jest.Mock).mock.results;
+ const { fastMovementsWarning, onWarningDismiss } =
+ useFastMovementsDetectionResults[useFastMovementsDetectionResults.length - 1].value;
+
+ expectPropsOnChildMock(Camera, {
+ hudProps: expect.objectContaining({
+ inspectionId: props.inspectionId,
+ maxRetryCount: props.maxRetryCount,
+ apiConfig: props.apiConfig,
+ minRecordingDuration: props.minRecordingDuration,
+ enforceOrientation: props.enforceOrientation,
+ isRecording: expect.any(Boolean),
+ setIsRecording: expect.any(Function),
+ alpha,
+ fastMovementsWarning,
+ onWarningDismiss,
+ startTasksLoading: expect.anything(),
+ onComplete: expect.any(Function),
+ }),
+ });
+
+ unmount();
+ });
+
+ it('should start the tasks on capture complete and then call the onComplete callback', async () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onSuccess } = (VideoCapturePermissions as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onSuccess();
+ });
+
+ const useStartTasksOnCompleteResult = (useStartTasksOnComplete as jest.Mock).mock.results;
+ const startTasks =
+ useStartTasksOnCompleteResult[useStartTasksOnCompleteResult.length - 1].value;
+
+ expectPropsOnChildMock(Camera, {
+ hudProps: expect.objectContaining({
+ onComplete: expect.any(Function),
+ }),
+ });
+ const { onComplete } = (Camera as jest.Mock).mock.calls[0][0].hudProps;
+
+ expect(startTasks).not.toHaveBeenCalled();
+ expect(props.onComplete).not.toHaveBeenCalled();
+ act(() => {
+ onComplete();
+ });
+
+ await waitFor(() => {
+ expect(startTasks).toHaveBeenCalled();
+ expect(props.onComplete).toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx
new file mode 100644
index 000000000..3fc1ef1f9
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx
@@ -0,0 +1,415 @@
+jest.mock('../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial', () => ({
+ VideoCaptureTutorial: jest.fn(() => <>>),
+}));
+jest.mock('../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording', () => ({
+ VideoCaptureRecording: jest.fn(() => <>>),
+}));
+jest.mock('../../../src/VideoCapture/VideoCaptureProcessing', () => ({
+ VideoCaptureProcessing: jest.fn(() => <>>),
+}));
+jest.mock('../../../src/VideoCapture/hooks', () => ({
+ ...jest.requireActual('../../../src/VideoCapture/hooks'),
+ useVehicleWalkaround: jest.fn(() => ({ walkaroundPosition: 334, startWalkaround: jest.fn() })),
+ useVideoUploadQueue: jest.fn(() => ({
+ uploadedFrames: 154,
+ totalUploadingFrames: 987,
+ onFrameSelected: jest.fn(),
+ })),
+ useFrameSelection: jest.fn(() => ({
+ processedFrames: 986,
+ totalProcessingFrames: 6782,
+ onCaptureVideoFrame: jest.fn(),
+ })),
+ useVideoRecording: jest.fn(() => ({
+ isRecordingPaused: true,
+ onClickRecordVideo: jest.fn(),
+ onDiscardDialogKeepRecording: jest.fn(),
+ onDiscardDialogDiscardVideo: jest.fn(),
+ isDiscardDialogDisplayed: false,
+ recordingDurationMs: 234,
+ pauseRecording: jest.fn(),
+ resumeRecording: jest.fn(),
+ tooltip: null,
+ })),
+}));
+
+import { act, render, screen } from '@testing-library/react';
+import { CameraHandle } from '@monkvision/camera-web';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { DeviceOrientation } from '@monkvision/types';
+import { LoadingState } from '@monkvision/common';
+import { ImageUploadType, useMonkApi } from '@monkvision/network';
+import { BackdropDialog } from '@monkvision/common-ui-web';
+import { VideoCaptureHUD, VideoCaptureHUDProps } from '../../../src/VideoCapture/VideoCaptureHUD';
+import { VideoCaptureRecording } from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording';
+import { VideoCaptureTutorial } from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial';
+import { VideoCaptureProcessing } from '../../../src/VideoCapture/VideoCaptureProcessing';
+import {
+ FastMovementType,
+ useFrameSelection,
+ useVehicleWalkaround,
+ useVideoRecording,
+ useVideoUploadQueue,
+ VideoRecordingTooltip,
+} from '../../../src/VideoCapture/hooks';
+
+const CAMERA_TEST_ID = 'test-id';
+
+function createProps(): VideoCaptureHUDProps {
+ return {
+ handle: {
+ takePicture: jest.fn(),
+ } as unknown as CameraHandle,
+ cameraPreview: ,
+ inspectionId: 'test-inspection-id',
+ apiConfig: {
+ apiDomain: 'test-api-domain',
+ authToken: 'test-auth-token',
+ thumbnailDomain: 'test-thumbnail-domain',
+ },
+ isRecording: true,
+ setIsRecording: jest.fn(),
+ enforceOrientation: DeviceOrientation.LANDSCAPE,
+ alpha: 12,
+ fastMovementsWarning: null,
+ onWarningDismiss: jest.fn(),
+ maxRetryCount: 24,
+ minRecordingDuration: 667,
+ startTasksLoading: { isLoading: false } as unknown as LoadingState,
+ onComplete: jest.fn(),
+ };
+}
+
+describe('VideoCaptureHUD component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should show the camera preview on the screen', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(screen.queryByTestId(CAMERA_TEST_ID)).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should pass the proper params to the useVehicleWalkaround hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(useVehicleWalkaround).toHaveBeenCalledWith(
+ expect.objectContaining({ alpha: props.alpha }),
+ );
+
+ unmount();
+ });
+
+ it('should pass the apiConfig to the useMonkApi hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(useMonkApi).toHaveBeenCalledWith(props.apiConfig);
+
+ unmount();
+ });
+
+ it('should pass the proper props to the useVideoUploadQueue hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(useVideoUploadQueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ apiConfig: props.apiConfig,
+ inspectionId: props.inspectionId,
+ maxRetryCount: props.maxRetryCount,
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should pass the proper props to the useFrameSelection hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onFrameSelected } = (useVideoUploadQueue as jest.Mock).mock.results[0].value;
+ expect(useFrameSelection).toHaveBeenCalledWith(
+ expect.objectContaining({
+ handle: props.handle,
+ frameSelectionInterval: 1000,
+ onFrameSelected,
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should pass the proper props to the useVideoRecording hook', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onCaptureVideoFrame } = (useFrameSelection as jest.Mock).mock.results[0].value;
+ const { walkaroundPosition, startWalkaround } = (useVehicleWalkaround as jest.Mock).mock
+ .results[0].value;
+ expect(useVideoRecording).toHaveBeenCalledWith(
+ expect.objectContaining({
+ isRecording: props.isRecording,
+ setIsRecording: props.setIsRecording,
+ screenshotInterval: 200,
+ minRecordingDuration: props.minRecordingDuration,
+ enforceOrientation: props.enforceOrientation,
+ walkaroundPosition,
+ startWalkaround,
+ onCaptureVideoFrame,
+ onRecordingComplete: expect.any(Function),
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should show the VideoCapture tutorial at the start', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(VideoCaptureTutorial).toHaveBeenCalled();
+ expect(VideoCaptureRecording).not.toHaveBeenCalled();
+ expect(VideoCaptureProcessing).not.toHaveBeenCalled();
+ expect(BackdropDialog).not.toHaveBeenCalledWith(
+ expect.objectContaining({ show: true }),
+ expect.anything(),
+ );
+
+ unmount();
+ });
+
+ it('should skip to the VideoRecording when closing the tutorial', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCaptureTutorial, { onClose: expect.any(Function) });
+ const { onClose } = (VideoCaptureTutorial as jest.Mock).mock.calls[0][0];
+ expect(VideoCaptureRecording).not.toHaveBeenCalled();
+ act(() => {
+ onClose();
+ });
+ expect(VideoCaptureRecording).toHaveBeenCalled();
+ expect(VideoCaptureProcessing).not.toHaveBeenCalled();
+ expect(BackdropDialog).not.toHaveBeenCalledWith(
+ expect.objectContaining({ show: true }),
+ expect.anything(),
+ );
+
+ unmount();
+ });
+
+ it('should pass the proper props to the VideoCaptureRecording component', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onClose } = (VideoCaptureTutorial as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onClose();
+ });
+ const { walkaroundPosition } = (useVehicleWalkaround as jest.Mock).mock.results[0].value;
+ const useVideoRecordingResults = (useVideoRecording as jest.Mock).mock.results;
+ const { isRecordingPaused, recordingDurationMs, onClickRecordVideo } =
+ useVideoRecordingResults[useVideoRecordingResults.length - 1].value;
+ expectPropsOnChildMock(VideoCaptureRecording, {
+ walkaroundPosition,
+ isRecording: props.isRecording,
+ isRecordingPaused,
+ recordingDurationMs,
+ onClickRecordVideo,
+ tooltip: undefined,
+ });
+
+ unmount();
+ });
+
+ it('should pass the proper tooltip label for the Start tooltip', () => {
+ const mockResult = (useVideoRecording as jest.Mock)();
+ (useVideoRecording as jest.Mock).mockImplementation(() => ({
+ ...mockResult,
+ tooltip: VideoRecordingTooltip.START,
+ }));
+
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onClose } = (VideoCaptureTutorial as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onClose();
+ });
+ expectPropsOnChildMock(VideoCaptureRecording, {
+ tooltip: 'video.recording.tooltip.start',
+ });
+
+ unmount();
+ });
+
+ it('should pass the proper tooltip label for the End tooltip', () => {
+ const mockResult = (useVideoRecording as jest.Mock)();
+ (useVideoRecording as jest.Mock).mockImplementation(() => ({
+ ...mockResult,
+ tooltip: VideoRecordingTooltip.END,
+ }));
+
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onClose } = (VideoCaptureTutorial as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onClose();
+ });
+ expectPropsOnChildMock(VideoCaptureRecording, {
+ tooltip: 'video.recording.tooltip.end',
+ });
+
+ unmount();
+ });
+
+ it('should take a picture and upload it when pressing on the take picture button', async () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onClose } = (VideoCaptureTutorial as jest.Mock).mock.calls[0][0];
+ act(() => {
+ onClose();
+ });
+ const picture = { test: 'picture' };
+ (props.handle.takePicture as jest.Mock).mockImplementationOnce(() => Promise.resolve(picture));
+ const { addImage } = (useMonkApi as jest.Mock).mock.results[0].value;
+ expectPropsOnChildMock(VideoCaptureRecording, { onClickTakePicture: expect.any(Function) });
+ const { onClickTakePicture } = (VideoCaptureRecording as jest.Mock).mock.calls[0][0];
+
+ expect(props.handle.takePicture).not.toHaveBeenCalled();
+ expect(addImage).not.toHaveBeenCalled();
+ await act(async () => {
+ await onClickTakePicture();
+ });
+ expect(props.handle.takePicture).toHaveBeenCalled();
+ expect(addImage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ uploadType: ImageUploadType.VIDEO_MANUAL_PHOTO,
+ inspectionId: props.inspectionId,
+ picture,
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should move on to the VideoCaptureProcessing on recording complete', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onRecordingComplete } = (useVideoRecording as jest.Mock).mock.calls[0][0];
+ expect(VideoCaptureProcessing).not.toHaveBeenCalled();
+ act(() => {
+ onRecordingComplete();
+ });
+ expect(VideoCaptureProcessing).toHaveBeenCalled();
+ expect(BackdropDialog).not.toHaveBeenCalledWith(
+ expect.objectContaining({ show: true }),
+ expect.anything(),
+ );
+
+ unmount();
+ });
+
+ it('should pass the proper props to the VideoCaptureProcessing component', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ const { onRecordingComplete } = (useVideoRecording as jest.Mock).mock.calls[0][0];
+ expect(VideoCaptureProcessing).not.toHaveBeenCalled();
+ act(() => {
+ onRecordingComplete();
+ });
+ const { processedFrames, totalProcessingFrames } = (useFrameSelection as jest.Mock).mock
+ .results[0].value;
+ const { uploadedFrames, totalUploadingFrames } = (useVideoUploadQueue as jest.Mock).mock
+ .results[0].value;
+ expectPropsOnChildMock(VideoCaptureProcessing, {
+ inspectionId: props.inspectionId,
+ processedFrames,
+ totalProcessingFrames,
+ uploadedFrames,
+ totalUploadingFrames,
+ loading: props.startTasksLoading,
+ onComplete: props.onComplete,
+ });
+
+ unmount();
+ });
+
+ it('should display a backdrop dialog when the user quits the video early', () => {
+ const props = createProps();
+ const { rerender, unmount } = render();
+
+ const onDiscardDialogKeepRecording = jest.fn();
+ const onDiscardDialogDiscardVideo = jest.fn();
+ (useVideoRecording as jest.Mock).mockImplementationOnce(() => ({
+ isRecordingPaused: true,
+ onClickRecordVideo: jest.fn(),
+ onDiscardDialogKeepRecording,
+ onDiscardDialogDiscardVideo,
+ isDiscardDialogDisplayed: true,
+ recordingDurationMs: 234,
+ pauseRecording: jest.fn(),
+ resumeRecording: jest.fn(),
+ }));
+ expect(BackdropDialog).not.toHaveBeenCalledWith(
+ expect.objectContaining({ show: true }),
+ expect.anything(),
+ );
+ rerender();
+ expectPropsOnChildMock(BackdropDialog, {
+ show: true,
+ message: 'video.recording.discardDialog.message',
+ confirmLabel: 'video.recording.discardDialog.keepRecording',
+ cancelLabel: 'video.recording.discardDialog.discardVideo',
+ onConfirm: onDiscardDialogKeepRecording,
+ onCancel: onDiscardDialogDiscardVideo,
+ });
+
+ unmount();
+ });
+
+ it('should display a backdrop dialog when the user moves too fast', () => {
+ const props = createProps();
+ const { rerender, unmount } = render();
+
+ expect(BackdropDialog).not.toHaveBeenCalledWith(
+ expect.objectContaining({ show: true }),
+ expect.anything(),
+ );
+ props.fastMovementsWarning = FastMovementType.WALKING_TOO_FAST;
+ rerender();
+ expectPropsOnChildMock(BackdropDialog, {
+ show: true,
+ message: 'video.recording.fastMovementsDialog.walkingTooFast',
+ confirmLabel: 'video.recording.fastMovementsDialog.confirm',
+ onConfirm: props.onWarningDismiss,
+ showCancelButton: false,
+ dialogIcon: 'warning-outline',
+ dialogIconPrimaryColor: 'caution',
+ });
+ props.fastMovementsWarning = FastMovementType.PHONE_SHAKING;
+ (BackdropDialog as jest.Mock).mockClear();
+ rerender();
+ expectPropsOnChildMock(BackdropDialog, {
+ show: true,
+ message: 'video.recording.fastMovementsDialog.phoneShaking',
+ confirmLabel: 'video.recording.fastMovementsDialog.confirm',
+ onConfirm: props.onWarningDismiss,
+ showCancelButton: false,
+ dialogIcon: 'warning-outline',
+ dialogIconPrimaryColor: 'caution',
+ });
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx
new file mode 100644
index 000000000..eb41c1dd8
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx
@@ -0,0 +1,182 @@
+import '@testing-library/jest-dom';
+import { act, render, screen } from '@testing-library/react';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import {
+ RecordVideoButton,
+ TakePictureButton,
+ VehicleWalkaroundIndicator,
+} from '@monkvision/common-ui-web';
+import {
+ VideoCaptureRecording,
+ VideoCaptureRecordingProps,
+} from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording';
+import { useWindowDimensions } from '@monkvision/common';
+
+const VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID = 'walkaround-indicator-container';
+
+function createProps(): VideoCaptureRecordingProps {
+ return {
+ walkaroundPosition: 200,
+ isRecording: false,
+ isRecordingPaused: false,
+ recordingDurationMs: 75800,
+ onClickRecordVideo: jest.fn(),
+ onClickTakePicture: jest.fn(),
+ tooltip: 'test-tooltip',
+ };
+}
+
+describe('VideoCaptureRecording component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should display the VehicleWalkaroundIndicator component', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VehicleWalkaroundIndicator, { alpha: props.walkaroundPosition });
+
+ unmount();
+ });
+
+ it('should change the style of the VehicleWalkaroundIndicator component when not recording', () => {
+ const props = createProps();
+ const disabledStyle = {
+ filter: 'grayscale(1)',
+ opacity: 0.7,
+ };
+ const { rerender, unmount } = render(
+ ,
+ );
+ expect(screen.getByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).not.toHaveStyle(
+ disabledStyle,
+ );
+
+ rerender();
+ expect(screen.getByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).toHaveStyle(
+ disabledStyle,
+ );
+
+ rerender();
+ expect(screen.getByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).toHaveStyle(
+ disabledStyle,
+ );
+
+ unmount();
+ });
+
+ it('should display the RecordVideoButton and pass it the recording state', () => {
+ const props = createProps();
+ const { rerender, unmount } = render();
+
+ expectPropsOnChildMock(RecordVideoButton, { isRecording: true });
+ rerender();
+ expectPropsOnChildMock(RecordVideoButton, { isRecording: false });
+
+ unmount();
+ });
+
+ it('should call the onClickRecordVideo callback when the user clicks on the RecordVideoButton', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(RecordVideoButton, { onClick: expect.any(Function) });
+ const { onClick } = (RecordVideoButton as unknown as jest.Mock).mock.calls[0][0];
+ expect(props.onClickRecordVideo).not.toHaveBeenCalled();
+ act(() => {
+ onClick();
+ });
+ expect(props.onClickRecordVideo).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should pass the tooltip to the RecordVideoButton component', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(RecordVideoButton, { tooltip: props.tooltip });
+
+ unmount();
+ });
+
+ it('should pass set the RecordVideoButton tooltip position to up when in portrait', () => {
+ (useWindowDimensions as jest.Mock).mockImplementationOnce(() => ({ isPortrait: true }));
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(RecordVideoButton, { tooltipPosition: 'up' });
+
+ unmount();
+ });
+
+ it('should pass set the RecordVideoButton tooltip position to left when in landscape', () => {
+ (useWindowDimensions as jest.Mock).mockImplementationOnce(() => ({ isPortrait: false }));
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(RecordVideoButton, { tooltipPosition: 'left' });
+
+ unmount();
+ });
+
+ it('should display the TakePictureButton', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expect(TakePictureButton).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should disable the TakePictureButton when not recording', () => {
+ const props = createProps();
+ const { rerender, unmount } = render();
+
+ expectPropsOnChildMock(TakePictureButton, { disabled: false });
+ rerender();
+ expectPropsOnChildMock(TakePictureButton, { disabled: true });
+
+ unmount();
+ });
+
+ it('should call the onClickTakePicture callback when the user clicks on the TakePictureButton', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(TakePictureButton, { onClick: expect.any(Function) });
+ const { onClick } = (TakePictureButton as unknown as jest.Mock).mock.calls[0][0];
+ expect(props.onClickTakePicture).not.toHaveBeenCalled();
+ act(() => {
+ onClick();
+ });
+ expect(props.onClickTakePicture).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should display the current recording time properly formatted when recording or recording paused', () => {
+ const props = createProps();
+ const { rerender, unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByText('01:15')).not.toBeNull();
+ rerender();
+ expect(screen.queryByText('01:15')).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should not display the current recording time when not recording', () => {
+ const props = createProps();
+ const { unmount } = render(
+ ,
+ );
+
+ expect(screen.queryByText('01:15')).toBeNull();
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial.test.tsx
new file mode 100644
index 000000000..45dba05a3
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial.test.tsx
@@ -0,0 +1,75 @@
+jest.mock('../../../src/VideoCapture/VideoCapturePageLayout', () => ({
+ VideoCapturePageLayout: jest.fn(() => <>>),
+ PageLayoutItem: jest.fn(() => <>>),
+}));
+
+import { render } from '@testing-library/react';
+import {
+ PageLayoutItem,
+ VideoCapturePageLayout,
+} from '../../../src/VideoCapture/VideoCapturePageLayout';
+import { VideoCaptureTutorial } from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+
+describe('VideoCaptureTutorial component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should use the VideoCapturePageLayout component for the layout', () => {
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, { showBackdrop: true });
+
+ unmount();
+ });
+
+ it('should pass the onClose callback to the confirm button', () => {
+ const onClose = jest.fn();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: { children: 'video.tutorial.confirm', onClick: expect.any(Function) },
+ });
+ const { onClick } = (VideoCapturePageLayout as jest.Mock).mock.calls[0][0].confirmButtonProps;
+ expect(onClose).not.toHaveBeenCalled();
+ onClick();
+ expect(onClose).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should display one item per tutorial step', () => {
+ const { unmount } = render();
+ unmount();
+
+ expect(PageLayoutItem).not.toHaveBeenCalled();
+ const { children } = (VideoCapturePageLayout as jest.Mock).mock.calls[0][0];
+ const { unmount: unmount2 } = render(children);
+ [
+ {
+ icon: 'car-arrow',
+ title: 'video.tutorial.start.title',
+ description: 'video.tutorial.start.description',
+ },
+ {
+ icon: '360',
+ title: 'video.tutorial.finish.title',
+ description: 'video.tutorial.finish.description',
+ },
+ {
+ icon: 'circle-dot',
+ title: 'video.tutorial.photos.title',
+ description: 'video.tutorial.photos.description',
+ },
+ ].forEach(({ icon, title, description }) => {
+ expectPropsOnChildMock(PageLayoutItem, {
+ icon,
+ title,
+ description,
+ });
+ });
+
+ unmount2();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCapturePageLayout/PageLayoutItem.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePageLayout/PageLayoutItem.test.tsx
new file mode 100644
index 000000000..3835dee05
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePageLayout/PageLayoutItem.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from '@testing-library/react';
+import {
+ PageLayoutItem,
+ PageLayoutItemProps,
+} from '../../../src/VideoCapture/VideoCapturePageLayout';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { Icon } from '@monkvision/common-ui-web';
+
+const props: PageLayoutItemProps = {
+ icon: 'add-photo',
+ title: 'test-title-wow',
+ description: 'test description test test',
+};
+
+describe('PageLayoutItem component', () => {
+ it('should display an icon with the given name', () => {
+ const { unmount } = render();
+
+ expectPropsOnChildMock(Icon, { icon: props.icon });
+
+ unmount();
+ });
+
+ it('should display the given title', () => {
+ const { unmount } = render();
+
+ expect(screen.queryByText(props.title)).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should display the given description', () => {
+ const { unmount } = render();
+
+ expect(screen.queryByText(props.description)).not.toBeNull();
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.test.tsx
new file mode 100644
index 000000000..3827f3d3b
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.test.tsx
@@ -0,0 +1,76 @@
+import '@testing-library/jest-dom';
+import { render, screen } from '@testing-library/react';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { Button, DynamicSVG } from '@monkvision/common-ui-web';
+import { monkLogoSVG } from '../../../src/assets/logos.asset';
+import { VideoCapturePageLayout } from '../../../src/VideoCapture/VideoCapturePageLayout';
+
+describe('VideoCapturePageLayout component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should display the Monk Logo', () => {
+ const { unmount } = render();
+
+ expectPropsOnChildMock(DynamicSVG, { svg: monkLogoSVG });
+
+ unmount();
+ });
+
+ it('should not display a backdrop by default', () => {
+ const { container, unmount } = render();
+
+ expect(container.children.length).toEqual(1);
+ expect(container.children.item(0)).not.toHaveStyle({ backgroundColor: 'rgba(0, 0, 0, 0.5)' });
+
+ unmount();
+ });
+
+ it('should display a backdrop behind it if asked to', () => {
+ const { container, unmount } = render();
+
+ expect(container.children.length).toEqual(1);
+ expect(container.children.item(0)).toHaveStyle({ backgroundColor: 'rgba(0, 0, 0, 0.5)' });
+
+ unmount();
+ });
+
+ it('should display the title on the screen', () => {
+ const { unmount } = render();
+
+ expect(screen.queryByText('video.introduction.title')).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should not display the title if the showTitle prop is set to false', () => {
+ const { unmount } = render();
+
+ expect(screen.queryByText('video.introduction.title')).toBeNull();
+
+ unmount();
+ });
+
+ it('should display the children on the screen', () => {
+ const testId = 'test-id';
+ const { unmount } = render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId(testId)).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should display a confirm button on the screen and pass it down the props', () => {
+ const confirmButtonProps = { children: 'hello', onClick: () => {} };
+ const { unmount } = render();
+
+ expectPropsOnChildMock(Button, confirmButtonProps);
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCapturePermissions.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePermissions.test.tsx
new file mode 100644
index 000000000..ce0b72a11
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePermissions.test.tsx
@@ -0,0 +1,157 @@
+jest.mock('../../src/VideoCapture/VideoCapturePageLayout', () => ({
+ VideoCapturePageLayout: jest.fn(() => <>>),
+ PageLayoutItem: jest.fn(() => <>>),
+}));
+
+import { render, waitFor } from '@testing-library/react';
+import { useCameraPermission } from '@monkvision/camera-web';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { VideoCapturePermissions } from '../../src/VideoCapture/VideoCapturePermissions';
+import {
+ PageLayoutItem,
+ VideoCapturePageLayout,
+} from '../../src/VideoCapture/VideoCapturePageLayout';
+
+function expectVideoCapturePageLayoutConfirmButtonProps(): jest.Mock {
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: {
+ children: 'video.permissions.confirm',
+ loading: expect.anything(),
+ onClick: expect.any(Function),
+ },
+ });
+ const { onClick } = (VideoCapturePageLayout as jest.Mock).mock.calls[0][0].confirmButtonProps;
+ return onClick;
+}
+
+describe('VideoCapturePermissions component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should use the VideoCapturePageLayout component for the layout', () => {
+ const { unmount } = render();
+
+ expect(VideoCapturePageLayout).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should request compass and camera permissions when pressing on the button', async () => {
+ const requestCompassPermission = jest.fn(() => Promise.resolve());
+ const { unmount } = render(
+ ,
+ );
+
+ expect(useCameraPermission).toHaveBeenCalled();
+ const { requestCameraPermission } = (useCameraPermission as jest.Mock).mock.results[0].value;
+
+ expect(requestCompassPermission).not.toHaveBeenCalled();
+ expect(requestCameraPermission).not.toHaveBeenCalled();
+
+ const onClick = expectVideoCapturePageLayoutConfirmButtonProps();
+ onClick();
+
+ await waitFor(() => {
+ expect(requestCompassPermission).toHaveBeenCalled();
+ expect(requestCameraPermission).toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+
+ it('should call the onSuccess callback when the permissions are granted', async () => {
+ const onSuccess = jest.fn();
+ const requestCompassPermission = jest.fn(() => Promise.resolve());
+ const { unmount } = render(
+ ,
+ );
+
+ const onClick = expectVideoCapturePageLayoutConfirmButtonProps();
+ onClick();
+
+ await waitFor(() => {
+ expect(onSuccess).toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+
+ it('should not call the onSuccess callback if the compass permission fails', async () => {
+ const onSuccess = jest.fn();
+ const requestCompassPermission = jest.fn(() => Promise.reject());
+ const { unmount } = render(
+ ,
+ );
+
+ const onClick = expectVideoCapturePageLayoutConfirmButtonProps();
+ onClick();
+
+ await waitFor(() => {
+ expect(onSuccess).not.toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+
+ it('should not call the onSuccess callback if the camera permission fails', async () => {
+ const onSuccess = jest.fn();
+ const requestCompassPermission = jest.fn(() => Promise.resolve());
+ (useCameraPermission as jest.Mock).mockImplementationOnce(() => ({
+ requestCameraPermission: jest.fn(() => Promise.reject()),
+ }));
+ const { unmount } = render(
+ ,
+ );
+
+ const onClick = expectVideoCapturePageLayoutConfirmButtonProps();
+ onClick();
+
+ await waitFor(() => {
+ expect(onSuccess).not.toHaveBeenCalled();
+ });
+
+ unmount();
+ });
+
+ it('should display an item for the camera permission', () => {
+ const { unmount } = render();
+ unmount();
+
+ expect(PageLayoutItem).not.toHaveBeenCalled();
+ const { children } = (VideoCapturePageLayout as jest.Mock).mock.calls[0][0];
+ const { unmount: unmount2 } = render(children);
+ expectPropsOnChildMock(PageLayoutItem, {
+ icon: 'camera-outline',
+ title: 'video.permissions.camera.title',
+ description: 'video.permissions.camera.description',
+ });
+
+ unmount2();
+ });
+
+ it('should display an item for the compass permission', () => {
+ const { unmount } = render();
+ unmount();
+
+ expect(PageLayoutItem).not.toHaveBeenCalled();
+ const { children } = (VideoCapturePageLayout as jest.Mock).mock.calls[0][0];
+ const { unmount: unmount2 } = render(children);
+ expectPropsOnChildMock(PageLayoutItem, {
+ icon: 'compass-outline',
+ title: 'video.permissions.compass.title',
+ description: 'video.permissions.compass.description',
+ });
+
+ unmount2();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureProcessing.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureProcessing.test.tsx
new file mode 100644
index 000000000..88d9c2682
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureProcessing.test.tsx
@@ -0,0 +1,245 @@
+import { LoadingState } from '@monkvision/common';
+
+jest.mock('../../src/VideoCapture/VideoCapturePageLayout', () => ({
+ VideoCapturePageLayout: jest.fn(({ children }) => <>{children}>),
+}));
+
+import '@testing-library/jest-dom';
+import { render, screen } from '@testing-library/react';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import {
+ VideoCaptureProcessing,
+ VideoCaptureProcessingProps,
+} from '../../src/VideoCapture/VideoCaptureProcessing';
+import { VideoCapturePageLayout } from '../../src/VideoCapture/VideoCapturePageLayout';
+
+function createProps(): VideoCaptureProcessingProps {
+ return {
+ inspectionId: 'test-id',
+ processedFrames: 123,
+ totalProcessingFrames: 456,
+ uploadedFrames: 345,
+ totalUploadingFrames: 567,
+ loading: { isLoading: false, error: undefined } as LoadingState,
+ onComplete: jest.fn(),
+ };
+}
+
+const PROGRESS_BAR_TEST_ID = 'progress-bar';
+
+describe('VideoCaptureProcessing component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should display the processing progress as a progress bar', () => {
+ const props = createProps();
+ const progress = props.processedFrames / props.totalProcessingFrames;
+ const { unmount } = render();
+
+ expect(screen.getByTestId(PROGRESS_BAR_TEST_ID)).toHaveStyle({
+ width: `${progress * 100}%`,
+ });
+
+ unmount();
+ });
+
+ it('should display the uploading progress as a progress bar when the processing is completed', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ const progress = props.uploadedFrames / props.totalUploadingFrames;
+ const { unmount } = render();
+
+ expect(screen.getByTestId(PROGRESS_BAR_TEST_ID)).toHaveStyle({
+ width: `${progress * 100}%`,
+ });
+
+ unmount();
+ });
+
+ it('should display the correct labels for the processing progress', () => {
+ const props = createProps();
+ const progress = props.processedFrames / props.totalProcessingFrames;
+ const { unmount } = render();
+
+ expect(screen.queryByText(`${Math.floor(progress * 100)}%`)).not.toBeNull();
+ expect(screen.queryByText('video.processing.processing')).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should display the correct labels for the uploading progress', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ const progress = props.uploadedFrames / props.totalUploadingFrames;
+ const { unmount } = render();
+
+ expect(screen.queryByText(`${Math.floor(progress * 100)}%`)).not.toBeNull();
+ expect(screen.queryByText('video.processing.uploading')).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should display the correct labels when the processing and uploading are completed', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ props.uploadedFrames = 1;
+ props.totalUploadingFrames = 1;
+ const { unmount } = render();
+
+ expect(screen.queryByText('100%')).not.toBeNull();
+ expect(screen.queryByText('video.processing.success')).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should display an error message when the loading state is in error', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ props.uploadedFrames = 1;
+ props.totalUploadingFrames = 1;
+ props.loading.error = { test: 'error' };
+ const { unmount } = render();
+
+ expect(screen.queryByText(`video.processing.error ${props.inspectionId}`)).not.toBeNull();
+
+ unmount();
+ });
+
+ it('should call the onComplete callback when clicking on the done button', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ onClick: expect.any(Function) }),
+ });
+ const { onClick } = (VideoCapturePageLayout as jest.Mock).mock.calls[0][0].confirmButtonProps;
+ expect(props.onComplete).not.toHaveBeenCalled();
+ onClick();
+ expect(props.onComplete).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should pass the proper label to the done button', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ children: 'video.processing.done' }),
+ });
+
+ unmount();
+ });
+
+ it('should disable the done button when the processing is not completed', () => {
+ const props = createProps();
+ props.uploadedFrames = 1;
+ props.totalUploadingFrames = 1;
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ disabled: true }),
+ });
+
+ unmount();
+ });
+
+ it('should disable the done button when the uploading is not completed', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ disabled: true }),
+ });
+
+ unmount();
+ });
+
+ it('should disable the done button when the loadingstate is in error', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ props.uploadedFrames = 1;
+ props.totalUploadingFrames = 1;
+ props.loading.error = { test: 'error' };
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ disabled: true }),
+ });
+
+ unmount();
+ });
+
+ it('should not disable the done button when the processing and uploading are completed', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ props.uploadedFrames = 1;
+ props.totalUploadingFrames = 1;
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ disabled: false }),
+ });
+
+ unmount();
+ });
+
+ it('should set the done button in loading mode when the loading state is loading', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ props.uploadedFrames = 1;
+ props.totalUploadingFrames = 1;
+ props.loading.isLoading = true;
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ loading: true }),
+ });
+
+ unmount();
+ });
+
+ it('should not set the done button in loading mode when the loading state is not loading', () => {
+ const props = createProps();
+ props.processedFrames = 1;
+ props.totalProcessingFrames = 1;
+ props.uploadedFrames = 1;
+ props.totalUploadingFrames = 1;
+ props.loading.isLoading = false;
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, {
+ confirmButtonProps: expect.objectContaining({ loading: false }),
+ });
+
+ unmount();
+ });
+
+ it('should not show the page layout title', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, { showTitle: false });
+
+ unmount();
+ });
+
+ it('should show the page layout backdrop', () => {
+ const props = createProps();
+ const { unmount } = render();
+
+ expectPropsOnChildMock(VideoCapturePageLayout, { showBackdrop: true });
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/hooks/useFastMovementsDetection.test.ts b/packages/inspection-capture-web/test/VideoCapture/hooks/useFastMovementsDetection.test.ts
new file mode 100644
index 000000000..97e6c0df1
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useFastMovementsDetection.test.ts
@@ -0,0 +1,221 @@
+jest.mock(
+ '../../../src/VideoCapture/hooks/useFastMovementsDetection/fastMovementsDetection',
+ () => ({
+ ...jest.requireActual(
+ '../../../src/VideoCapture/hooks/useFastMovementsDetection/fastMovementsDetection',
+ ),
+ detectFastMovements: jest.fn(() => null),
+ }),
+);
+
+import { act } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import {
+ FastMovementType,
+ useFastMovementsDetection,
+ UseFastMovementsDetectionParams,
+} from '../../../src/VideoCapture/hooks';
+import { detectFastMovements } from '../../../src/VideoCapture/hooks/useFastMovementsDetection/fastMovementsDetection';
+
+function createProps(): UseFastMovementsDetectionParams {
+ return {
+ isRecording: true,
+ enableFastWalkingWarning: true,
+ enablePhoneShakingWarning: true,
+ fastWalkingWarningCooldown: 2000,
+ phoneShakingWarningCooldown: 3000,
+ };
+}
+
+function createEvent(): DeviceOrientationEvent {
+ return {
+ alpha: Math.random() * 360,
+ beta: Math.random() * 360 - 180,
+ gamma: Math.random() * 360 - 180,
+ } as unknown as DeviceOrientationEvent;
+}
+
+function detectNextMovement(type: FastMovementType | null): void {
+ (detectFastMovements as jest.Mock).mockImplementationOnce(() => type);
+}
+
+jest.useFakeTimers();
+
+describe('useFastMovementsDetection hook', () => {
+ afterEach(() => {
+ (detectFastMovements as jest.Mock).mockRestore();
+ jest.clearAllMocks();
+ });
+
+ it('should return the proper initial state', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ expect(result.current.onWarningDismiss).toEqual(expect.any(Function));
+ expect(result.current.fastMovementsWarning).toBeNull();
+ expect(result.current.onWarningDismiss).toEqual(expect.any(Function));
+
+ unmount();
+ });
+
+ it('should call the detectFastMovements function with the proper params', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ const event1 = createEvent();
+ act(() => {
+ result.current.onDeviceOrientationEvent(event1);
+ });
+ expect(detectFastMovements).toHaveBeenCalledWith(
+ expect.objectContaining(event1),
+ expect.objectContaining({ alpha: 0, beta: 0, gamma: 0 }),
+ );
+ const event2 = createEvent();
+ act(() => {
+ result.current.onDeviceOrientationEvent(event2);
+ });
+ expect(detectFastMovements).toHaveBeenCalledWith(
+ expect.objectContaining(event2),
+ expect.objectContaining(event1),
+ );
+
+ unmount();
+ });
+
+ it('should not display a warning if no warning was detected', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ const event = createEvent();
+ act(() => {
+ result.current.onDeviceOrientationEvent(event);
+ });
+ expect(detectFastMovements).toHaveBeenCalledWith(
+ expect.objectContaining(event),
+ expect.objectContaining({ alpha: 0, beta: 0, gamma: 0 }),
+ );
+ expect(result.current.fastMovementsWarning).toBeNull();
+
+ unmount();
+ });
+
+ it('should not display a warning if the video is not recording', () => {
+ const initialProps = createProps();
+ initialProps.isRecording = false;
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ detectNextMovement(FastMovementType.PHONE_SHAKING);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toBeNull();
+ detectNextMovement(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toBeNull();
+
+ unmount();
+ });
+
+ it('should not display the fast walking warning if it is disabled', () => {
+ const initialProps = createProps();
+ initialProps.enableFastWalkingWarning = false;
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ detectNextMovement(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toBeNull();
+
+ unmount();
+ });
+
+ it('should not display the phone shaking warning if it is disabled', () => {
+ const initialProps = createProps();
+ initialProps.enablePhoneShakingWarning = false;
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ detectNextMovement(FastMovementType.PHONE_SHAKING);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toBeNull();
+
+ unmount();
+ });
+
+ it('should display the detected warning and dismiss it properly', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ detectNextMovement(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toEqual(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onWarningDismiss();
+ });
+ expect(result.current.fastMovementsWarning).toBeNull();
+
+ unmount();
+ });
+
+ it('should not display the same warning if it is on cooldown', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFastMovementsDetection, { initialProps });
+
+ detectNextMovement(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ act(() => {
+ result.current.onWarningDismiss();
+ });
+
+ jest.advanceTimersByTime(initialProps.fastWalkingWarningCooldown - 1);
+ detectNextMovement(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toBeNull();
+
+ jest.advanceTimersByTime(2);
+ detectNextMovement(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toEqual(FastMovementType.WALKING_TOO_FAST);
+ act(() => {
+ result.current.onWarningDismiss();
+ });
+
+ jest.advanceTimersByTime(5);
+ detectNextMovement(FastMovementType.PHONE_SHAKING);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toEqual(FastMovementType.PHONE_SHAKING);
+ act(() => {
+ result.current.onWarningDismiss();
+ });
+
+ jest.advanceTimersByTime(initialProps.phoneShakingWarningCooldown - 1);
+ detectNextMovement(FastMovementType.PHONE_SHAKING);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toBeNull();
+
+ jest.advanceTimersByTime(2);
+ detectNextMovement(FastMovementType.PHONE_SHAKING);
+ act(() => {
+ result.current.onDeviceOrientationEvent(createEvent());
+ });
+ expect(result.current.fastMovementsWarning).toEqual(FastMovementType.PHONE_SHAKING);
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/hooks/useFrameSelection.test.ts b/packages/inspection-capture-web/test/VideoCapture/hooks/useFrameSelection.test.ts
new file mode 100644
index 000000000..f6d69affa
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useFrameSelection.test.ts
@@ -0,0 +1,172 @@
+jest.mock('../../../src/VideoCapture/hooks/useFrameSelection/laplaceScores', () => ({
+ calculateLaplaceScores: jest.fn(() => ({ mean: 0, std: 0 })),
+}));
+
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useInterval, useQueue } from '@monkvision/common';
+import { useFrameSelection, UseFrameSelectionParams } from '../../../src/VideoCapture/hooks';
+import { calculateLaplaceScores } from '../../../src/VideoCapture/hooks/useFrameSelection/laplaceScores';
+
+function createProps(): UseFrameSelectionParams {
+ return {
+ handle: {
+ getImageData: jest.fn(() => ({ data: [0, 2], width: 123, height: 456 })),
+ compressImage: jest.fn(() => Promise.resolve({ blob: {}, uri: 'test' })),
+ },
+ frameSelectionInterval: 1500,
+ onFrameSelected: jest.fn(),
+ } as unknown as UseFrameSelectionParams;
+}
+
+describe('useFrameSelection hook', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return an onCaptureVideoFrame callback', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFrameSelection, { initialProps });
+
+ expect(typeof result.current.onCaptureVideoFrame).toBe('function');
+
+ unmount();
+ });
+
+ it('should not select any frames if no screenshot has been taken', () => {
+ const initialProps = createProps();
+ const { unmount } = renderHook(useFrameSelection, { initialProps });
+
+ expect(useQueue).toHaveBeenCalled();
+ const { push } = (useQueue as jest.Mock).mock.results[0].value;
+ expect(push).not.toHaveBeenCalled();
+ expect(initialProps.handle.getImageData).not.toHaveBeenCalled();
+ expect(initialProps.handle.compressImage).not.toHaveBeenCalled();
+ expect(initialProps.onFrameSelected).not.toHaveBeenCalled();
+ expect(calculateLaplaceScores).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should push the image to the processing queue when a screenshot is taken', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFrameSelection, { initialProps });
+
+ expect(useQueue).toHaveBeenCalled();
+ const { push } = (useQueue as jest.Mock).mock.results[0].value;
+
+ expect(initialProps.handle.getImageData).not.toHaveBeenCalled();
+ expect(push).not.toHaveBeenCalled();
+ act(() => {
+ result.current.onCaptureVideoFrame();
+ });
+ expect(initialProps.handle.getImageData).toHaveBeenCalled();
+ const image = (initialProps.handle.getImageData as jest.Mock).mock.results[0].value;
+ expect(push).toHaveBeenCalledWith(image);
+
+ unmount();
+ });
+
+ it('should select the best frame using the laplace scoring function by making copies with the array.slice method', async () => {
+ const initialProps = createProps();
+ const { rerender, unmount } = renderHook(useFrameSelection, { initialProps });
+
+ expect(useQueue).toHaveBeenCalledWith(
+ expect.any(Function),
+ expect.objectContaining({ storeFailedItems: false }),
+ );
+ const processingFunction = (useQueue as jest.Mock).mock.calls[0][0];
+ (calculateLaplaceScores as jest.Mock).mockImplementation(([imageId]) => {
+ if (imageId === 1) {
+ return { std: 0.4 };
+ }
+ if (imageId === 2) {
+ return { std: 0.6 };
+ }
+ if (imageId === 3) {
+ return { std: 0.2 };
+ }
+ return null;
+ });
+
+ const image1 = {
+ data: { slice: jest.fn(() => [1]) },
+ width: 11,
+ height: 12,
+ } as unknown as ImageData;
+ const image2 = {
+ data: { slice: jest.fn(() => [2]) },
+ width: 21,
+ height: 22,
+ } as unknown as ImageData;
+ const image3 = {
+ data: { slice: jest.fn(() => [3]) },
+ width: 31,
+ height: 32,
+ } as unknown as ImageData;
+
+ expect(calculateLaplaceScores).not.toHaveBeenCalled();
+ await act(async () => {
+ await processingFunction(image1);
+ rerender();
+ await processingFunction(image2);
+ rerender();
+ await processingFunction(image3);
+ rerender();
+ });
+
+ expect(image1.data.slice).toHaveBeenCalled();
+ expect(image2.data.slice).toHaveBeenCalled();
+ expect(image3.data.slice).toHaveBeenCalled();
+ expect(calculateLaplaceScores).toHaveBeenCalledTimes(3);
+ expect(calculateLaplaceScores).toHaveBeenCalledWith(
+ image1.data.slice(),
+ image1.width,
+ image1.height,
+ );
+ expect(calculateLaplaceScores).toHaveBeenCalledWith(
+ image2.data.slice(),
+ image2.width,
+ image2.height,
+ );
+ expect(calculateLaplaceScores).toHaveBeenCalledWith(
+ image3.data.slice(),
+ image3.width,
+ image3.height,
+ );
+
+ expect(useInterval).toHaveBeenCalledWith(
+ expect.any(Function),
+ initialProps.frameSelectionInterval,
+ );
+ const callback = (useInterval as jest.Mock).mock.calls[0][0];
+ expect(initialProps.handle.compressImage).not.toHaveBeenCalled();
+ expect(initialProps.onFrameSelected).not.toHaveBeenCalled();
+ act(() => {
+ callback();
+ });
+ expect(initialProps.handle.compressImage).toHaveBeenCalledTimes(1);
+ expect(initialProps.handle.compressImage).toHaveBeenCalledWith(image2);
+ const picture = await (initialProps.handle.compressImage as jest.Mock).mock.results[0].value;
+ expect(initialProps.onFrameSelected).toHaveBeenCalledTimes(1);
+ expect(initialProps.onFrameSelected).toHaveBeenCalledWith(picture);
+
+ unmount();
+ });
+
+ it('should return the processed frames and the total processing frames', () => {
+ const totalItems = 4321;
+ const processingCount = 876;
+ (useQueue as jest.Mock).mockImplementationOnce(() => ({
+ push: jest.fn(),
+ totalItems,
+ processingCount,
+ }));
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useFrameSelection, { initialProps });
+
+ expect(result.current.processedFrames).toEqual(totalItems - processingCount);
+ expect(result.current.totalProcessingFrames).toEqual(totalItems);
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/hooks/useVehicleWalkaround.test.ts b/packages/inspection-capture-web/test/VideoCapture/hooks/useVehicleWalkaround.test.ts
new file mode 100644
index 000000000..7460e71bd
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useVehicleWalkaround.test.ts
@@ -0,0 +1,103 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useVehicleWalkaround } from '../../../src/VideoCapture/hooks';
+
+describe('useVehicleWalkaround hook', () => {
+ it('should return 0 when the walkaround has not been started', () => {
+ const { result, rerender, unmount } = renderHook(useVehicleWalkaround, {
+ initialProps: { alpha: 35 },
+ });
+
+ expect(result.current.walkaroundPosition).toEqual(0);
+ rerender({ alpha: 30 });
+ expect(result.current.walkaroundPosition).toEqual(0);
+ rerender({ alpha: 300 });
+ expect(result.current.walkaroundPosition).toEqual(0);
+
+ unmount();
+ });
+
+ it('should start updating the position with the proper values after the walkaround has started', () => {
+ const { result, rerender, unmount } = renderHook(useVehicleWalkaround, {
+ initialProps: { alpha: 67 },
+ });
+
+ expect(result.current.startWalkaround).toBeInstanceOf(Function);
+ act(() => result.current.startWalkaround());
+ expect(result.current.walkaroundPosition).toEqual(0);
+ rerender({ alpha: 64 });
+ expect(result.current.walkaroundPosition).toEqual(3);
+ rerender({ alpha: 40 });
+ expect(result.current.walkaroundPosition).toEqual(27);
+ rerender({ alpha: 14 });
+ expect(result.current.walkaroundPosition).toEqual(53);
+ rerender({ alpha: 334 });
+ expect(result.current.walkaroundPosition).toEqual(93);
+ rerender({ alpha: 294 });
+ expect(result.current.walkaroundPosition).toEqual(133);
+ rerender({ alpha: 259 });
+ expect(result.current.walkaroundPosition).toEqual(168);
+ rerender({ alpha: 227 });
+ expect(result.current.walkaroundPosition).toEqual(200);
+ rerender({ alpha: 197 });
+ expect(result.current.walkaroundPosition).toEqual(230);
+ rerender({ alpha: 167 });
+ expect(result.current.walkaroundPosition).toEqual(260);
+ rerender({ alpha: 137 });
+ expect(result.current.walkaroundPosition).toEqual(290);
+ rerender({ alpha: 107 });
+ expect(result.current.walkaroundPosition).toEqual(320);
+ rerender({ alpha: 77 });
+ expect(result.current.walkaroundPosition).toEqual(350);
+ rerender({ alpha: 68 });
+ expect(result.current.walkaroundPosition).toEqual(359);
+ rerender({ alpha: 30 });
+ expect(result.current.walkaroundPosition).toEqual(359);
+
+ unmount();
+ });
+
+ it('should return 0 if the user rotates past the next checkpoint', () => {
+ const { result, rerender, unmount } = renderHook(useVehicleWalkaround, {
+ initialProps: { alpha: 50 },
+ });
+
+ expect(result.current.startWalkaround).toBeInstanceOf(Function);
+ act(() => result.current.startWalkaround());
+ expect(result.current.walkaroundPosition).toEqual(0);
+ rerender({ alpha: 30 });
+ expect(result.current.walkaroundPosition).toEqual(20);
+ rerender({ alpha: 310 });
+ expect(result.current.walkaroundPosition).toEqual(0);
+ rerender({ alpha: 60 });
+ expect(result.current.walkaroundPosition).toEqual(0);
+ rerender({ alpha: 45 });
+ expect(result.current.walkaroundPosition).toEqual(5);
+ rerender({ alpha: 51 });
+ expect(result.current.walkaroundPosition).toEqual(0);
+
+ unmount();
+ });
+
+ it('should reset the position and checkpoints on start', () => {
+ const { result, rerender, unmount } = renderHook(useVehicleWalkaround, {
+ initialProps: { alpha: 67 },
+ });
+
+ expect(result.current.startWalkaround).toBeInstanceOf(Function);
+ act(() => result.current.startWalkaround());
+ expect(result.current.walkaroundPosition).toEqual(0);
+ rerender({ alpha: 64 });
+ expect(result.current.walkaroundPosition).toEqual(3);
+ rerender({ alpha: 40 });
+ expect(result.current.walkaroundPosition).toEqual(27);
+ act(() => {
+ result.current.startWalkaround();
+ });
+ rerender({ alpha: 38 });
+ expect(result.current.walkaroundPosition).toEqual(2);
+ rerender({ alpha: 45 });
+ expect(result.current.walkaroundPosition).toEqual(0);
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts
new file mode 100644
index 000000000..bad7659e9
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts
@@ -0,0 +1,247 @@
+import {
+ useVideoRecording,
+ UseVideoRecordingParams,
+ VideoRecordingTooltip,
+} from '../../../src/VideoCapture/hooks';
+import { renderHook } from '@testing-library/react-hooks';
+import { useInterval } from '@monkvision/common';
+import { act } from '@testing-library/react';
+
+function createProps(): UseVideoRecordingParams {
+ let isRecording = false;
+ return {
+ get isRecording() {
+ return isRecording;
+ },
+ setIsRecording: jest.fn((param) => {
+ isRecording = typeof param === 'boolean' ? param : param(isRecording);
+ }),
+ walkaroundPosition: 300,
+ startWalkaround: jest.fn(),
+ screenshotInterval: 200,
+ minRecordingDuration: 5000,
+ onCaptureVideoFrame: jest.fn(),
+ onRecordingComplete: jest.fn(),
+ };
+}
+
+jest.useFakeTimers();
+
+describe('useVideoRecording hook', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should start with the proper initial state', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ expect(result.current).toEqual(
+ expect.objectContaining({
+ isRecordingPaused: false,
+ recordingDurationMs: 0,
+ onClickRecordVideo: expect.any(Function),
+ onDiscardDialogKeepRecording: expect.any(Function),
+ onDiscardDialogDiscardVideo: expect.any(Function),
+ isDiscardDialogDisplayed: false,
+ pauseRecording: expect.any(Function),
+ resumeRecording: expect.any(Function),
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should not be taking screenshots when the video is not recording', () => {
+ const initialProps = createProps();
+ const { unmount } = renderHook(useVideoRecording, { initialProps });
+
+ expect(useInterval).toHaveBeenCalledWith(expect.anything(), null);
+
+ unmount();
+ });
+
+ it('should start taking screenshots when the user clicks on the recording button', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ expect(initialProps.isRecording).toBe(true);
+ expect(result.current.isRecordingPaused).toBe(false);
+ expect(useInterval).toHaveBeenCalledWith(expect.anything(), initialProps.screenshotInterval);
+ const callback = (useInterval as jest.Mock).mock.calls[
+ (useInterval as jest.Mock).mock.calls.length - 1
+ ][0];
+ expect(initialProps.onCaptureVideoFrame).not.toHaveBeenCalled();
+ callback();
+ expect(initialProps.onCaptureVideoFrame).toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should keep track of the recording length', () => {
+ const initialProps = createProps();
+ const { result, rerender, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ expect(result.current.recordingDurationMs).toEqual(0);
+ const time = 2547;
+ jest.advanceTimersByTime(time);
+ rerender();
+ expect(result.current.recordingDurationMs).toEqual(time);
+
+ unmount();
+ });
+
+ it('should display the discard warning and pause the recording when stopping the video too soon based on the recording time', () => {
+ const initialProps = createProps();
+ const { result, rerender, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ jest.advanceTimersByTime(initialProps.minRecordingDuration - 1);
+ rerender();
+ (useInterval as jest.Mock).mockClear();
+ expect(result.current.isDiscardDialogDisplayed).toBe(false);
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ expect(result.current.isDiscardDialogDisplayed).toBe(true);
+ expect(initialProps.isRecording).toBe(false);
+ expect(result.current.isRecordingPaused).toBe(true);
+ expect(useInterval).toHaveBeenCalledWith(expect.anything(), null);
+ expect(result.current.recordingDurationMs).toEqual(initialProps.minRecordingDuration - 1);
+ jest.advanceTimersByTime(4500);
+ rerender();
+ expect(result.current.recordingDurationMs).toEqual(initialProps.minRecordingDuration - 1);
+
+ unmount();
+ });
+
+ it('should display the discard warning and pause the recording when stopping the video too soon based on the walkaround position', () => {
+ const initialProps = createProps();
+ initialProps.walkaroundPosition = 269;
+ const { result, rerender, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ jest.advanceTimersByTime(initialProps.minRecordingDuration + 1);
+ rerender();
+ (useInterval as jest.Mock).mockClear();
+ expect(result.current.isDiscardDialogDisplayed).toBe(false);
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ expect(result.current.isDiscardDialogDisplayed).toBe(true);
+ expect(initialProps.isRecording).toBe(false);
+ expect(result.current.isRecordingPaused).toBe(true);
+ expect(useInterval).toHaveBeenCalledWith(expect.anything(), null);
+ expect(result.current.recordingDurationMs).toEqual(initialProps.minRecordingDuration + 1);
+ jest.advanceTimersByTime(4500);
+ rerender();
+ expect(result.current.recordingDurationMs).toEqual(initialProps.minRecordingDuration + 1);
+
+ unmount();
+ });
+
+ it('should resume the recording when the user presses on the keep recording button', () => {
+ const initialProps = createProps();
+ const { result, rerender, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ jest.advanceTimersByTime(initialProps.minRecordingDuration - 1);
+ rerender();
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ rerender();
+ (useInterval as jest.Mock).mockClear();
+ act(() => {
+ result.current.onDiscardDialogKeepRecording();
+ });
+ expect(result.current.isDiscardDialogDisplayed).toBe(false);
+ expect(initialProps.isRecording).toBe(true);
+ expect(result.current.isRecordingPaused).toBe(false);
+ expect(useInterval).toHaveBeenCalledWith(expect.anything(), initialProps.screenshotInterval);
+ expect(result.current.recordingDurationMs).toEqual(initialProps.minRecordingDuration - 1);
+ const time = 4500;
+ jest.advanceTimersByTime(time);
+ rerender();
+ expect(result.current.recordingDurationMs).toEqual(
+ time + initialProps.minRecordingDuration - 1,
+ );
+
+ unmount();
+ });
+
+ it('should stop the recording when the user presses on the discard video button', () => {
+ const initialProps = createProps();
+ const { result, rerender, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ jest.advanceTimersByTime(initialProps.minRecordingDuration - 1);
+ rerender();
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ rerender();
+ (useInterval as jest.Mock).mockClear();
+ act(() => {
+ result.current.onDiscardDialogDiscardVideo();
+ });
+ expect(result.current.isDiscardDialogDisplayed).toBe(false);
+ expect(useInterval).toHaveBeenCalledWith(expect.anything(), null);
+ expect(result.current.recordingDurationMs).toEqual(0);
+ expect(initialProps.isRecording).toBe(false);
+ expect(result.current.isRecordingPaused).toBe(false);
+ jest.advanceTimersByTime(4500);
+ rerender();
+ expect(result.current.recordingDurationMs).toEqual(0);
+
+ unmount();
+ });
+
+ it('should return the start tooltip initially', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ expect(result.current.tooltip).toEqual(VideoRecordingTooltip.START);
+
+ unmount();
+ });
+
+ it('should dismiss the initial tooltip once the user starts recording the video', () => {
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ expect(result.current.tooltip).toBeNull();
+
+ unmount();
+ });
+
+ it('should show the end tooltip once the compass reaches the end', () => {
+ const initialProps = createProps();
+ const { result, rerender, unmount } = renderHook(useVideoRecording, { initialProps });
+
+ act(() => {
+ result.current.onClickRecordVideo();
+ });
+ rerender({ ...initialProps, walkaroundPosition: 316 });
+ expect(result.current.tooltip).toEqual(VideoRecordingTooltip.END);
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoUploadQueue.test.ts b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoUploadQueue.test.ts
new file mode 100644
index 000000000..0474ef961
--- /dev/null
+++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoUploadQueue.test.ts
@@ -0,0 +1,152 @@
+import { act, renderHook } from '@testing-library/react-hooks';
+import { useQueue } from '@monkvision/common';
+import { MonkPicture } from '@monkvision/types';
+import { ImageUploadType, useMonkApi } from '@monkvision/network';
+import { useVideoUploadQueue, VideoUploadQueueParams } from '../../../src/VideoCapture/hooks';
+
+function createProps(): VideoUploadQueueParams {
+ return {
+ apiConfig: {
+ apiDomain: 'test-api-domain',
+ thumbnailDomain: 'test-thumbnail-domain',
+ authToken: 'auth-token',
+ },
+ inspectionId: 'inspection-test-id',
+ maxRetryCount: 3,
+ };
+}
+
+jest.useFakeTimers();
+
+describe('useVideoUploadQueue hook', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should push items to the queue with the proper params', () => {
+ const initialProps = createProps();
+ const { result, rerender, unmount } = renderHook(useVideoUploadQueue, { initialProps });
+
+ expect(useQueue).toHaveBeenCalled();
+ let { push } = (useQueue as jest.Mock).mock.results[0].value;
+
+ expect(push).not.toHaveBeenCalled();
+ const picture1 = { uri: 'test-uri-1' } as unknown as MonkPicture;
+ act(() => {
+ result.current.onFrameSelected(picture1);
+ });
+ expect(push).toHaveBeenCalledWith(
+ expect.objectContaining({
+ picture: picture1,
+ frameIndex: 0,
+ timestamp: 0,
+ }),
+ );
+
+ const time = 5491;
+ jest.advanceTimersByTime(time);
+ rerender();
+ push = (useQueue as jest.Mock).mock.results[1].value.push;
+
+ expect(push).not.toHaveBeenCalled();
+ const picture2 = { uri: 'test-uri-2' } as unknown as MonkPicture;
+ act(() => {
+ result.current.onFrameSelected(picture2);
+ });
+ expect(push).toHaveBeenCalledWith(
+ expect.objectContaining({
+ picture: picture2,
+ frameIndex: 1,
+ timestamp: time,
+ }),
+ );
+
+ unmount();
+ });
+
+ it('should upload the image to the API when adding the item to the queue', () => {
+ const initialProps = createProps();
+ const { unmount } = renderHook(useVideoUploadQueue, { initialProps });
+
+ expect(useMonkApi).toHaveBeenCalledWith(initialProps.apiConfig);
+ const { addImage } = (useMonkApi as jest.Mock).mock.results[0].value;
+
+ expect(useQueue).toHaveBeenCalledWith(expect.any(Function), expect.anything());
+ const processingFunction = (useQueue as jest.Mock).mock.calls[0][0];
+
+ expect(addImage).not.toHaveBeenCalled();
+ const upload = {
+ picture: { uri: 'test-uri-1' },
+ frameIndex: 12,
+ timestamp: 123,
+ retryCount: 0,
+ };
+ processingFunction(upload);
+ expect(addImage).toHaveBeenCalledWith({
+ uploadType: ImageUploadType.VIDEO_FRAME,
+ inspectionId: initialProps.inspectionId,
+ picture: upload.picture,
+ frameIndex: upload.frameIndex,
+ timestamp: upload.timestamp,
+ });
+
+ unmount();
+ });
+
+ it('should retry the failed items until they reach the retry limit', () => {
+ const initialProps = createProps();
+ const { unmount } = renderHook(useVideoUploadQueue, { initialProps });
+
+ expect(useQueue).toHaveBeenCalledWith(
+ expect.any(Function),
+ expect.objectContaining({
+ storeFailedItems: true,
+ onItemFail: expect.any(Function),
+ }),
+ );
+ const { push } = (useQueue as jest.Mock).mock.results[0].value;
+ const { onItemFail } = (useQueue as jest.Mock).mock.calls[0][1];
+ const upload = {
+ picture: { uri: 'test-uri-1' },
+ frameIndex: 12,
+ timestamp: 123,
+ retryCount: 0,
+ };
+
+ let retry = 0;
+ while (retry < initialProps.maxRetryCount) {
+ expect(push).not.toHaveBeenCalled();
+ onItemFail(upload);
+ expect(push).toHaveBeenCalledWith(
+ expect.objectContaining({
+ picture: upload.picture,
+ frameIndex: upload.frameIndex,
+ timestamp: upload.timestamp,
+ }),
+ );
+ push.mockClear();
+ retry += 1;
+ }
+ onItemFail(upload);
+ expect(push).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ it('should return the uploaded frames and the total uploading frames', () => {
+ const totalItems = 2345;
+ const processingCount = 123;
+ (useQueue as jest.Mock).mockImplementationOnce(() => ({
+ push: jest.fn(),
+ totalItems,
+ processingCount,
+ }));
+ const initialProps = createProps();
+ const { result, unmount } = renderHook(useVideoUploadQueue, { initialProps });
+
+ expect(result.current.uploadedFrames).toEqual(totalItems - processingCount);
+ expect(result.current.totalUploadingFrames).toEqual(totalItems);
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/components/OrientationEnforcer.test.tsx b/packages/inspection-capture-web/test/components/OrientationEnforcer.test.tsx
new file mode 100644
index 000000000..acc9112bb
--- /dev/null
+++ b/packages/inspection-capture-web/test/components/OrientationEnforcer.test.tsx
@@ -0,0 +1,46 @@
+jest.mock('../../src/hooks', () => ({
+ useEnforceOrientation: jest.fn(() => false),
+}));
+
+import '@testing-library/jest-dom';
+import { expectPropsOnChildMock } from '@monkvision/test-utils';
+import { render, screen } from '@testing-library/react';
+import { DeviceOrientation } from '@monkvision/types';
+import { useEnforceOrientation } from '../../src/hooks';
+import { OrientationEnforcer } from '../../src/components';
+import { Icon } from '@monkvision/common-ui-web';
+
+describe('OrientationEnforcer component', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return null if the orientation is valid', () => {
+ const orientation = DeviceOrientation.PORTRAIT;
+ const { container, unmount } = render();
+
+ expect(useEnforceOrientation).toHaveBeenCalledWith(orientation);
+ expect(container).toBeEmptyDOMElement();
+
+ unmount();
+ });
+
+ it('should display an error message if the orientation is not valid', () => {
+ (useEnforceOrientation as jest.Mock).mockImplementationOnce(() => true);
+ const orientation = DeviceOrientation.LANDSCAPE;
+ const { container, unmount } = render();
+
+ expect(useEnforceOrientation).toHaveBeenCalledWith(orientation);
+ expect(container).not.toBeEmptyDOMElement();
+
+ expect(screen.queryByText('orientationEnforcer.title')).not.toBeNull();
+ expect(screen.queryByText('orientationEnforcer.description')).not.toBeNull();
+ expectPropsOnChildMock(Icon, {
+ icon: 'rotate',
+ primaryColor: 'text-primary',
+ size: 30,
+ });
+
+ unmount();
+ });
+});
diff --git a/packages/inspection-capture-web/test/hooks/useEnforceOrientation.test.ts b/packages/inspection-capture-web/test/hooks/useEnforceOrientation.test.ts
new file mode 100644
index 000000000..22b24310a
--- /dev/null
+++ b/packages/inspection-capture-web/test/hooks/useEnforceOrientation.test.ts
@@ -0,0 +1,42 @@
+import { DeviceOrientation } from '@monkvision/types';
+import { renderHook } from '@testing-library/react-hooks';
+import { useEnforceOrientation } from '../../src/hooks';
+import { useWindowDimensions } from '@monkvision/common';
+
+describe('useEnforceOrientation hook', () => {
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('should return false when the orientation is not specified', () => {
+ const { result, unmount } = renderHook(useEnforceOrientation);
+
+ expect(result.current).toBe(false);
+
+ unmount();
+ });
+
+ [DeviceOrientation.PORTRAIT, DeviceOrientation.LANDSCAPE].forEach((orientation) => {
+ it(`should return false when the orientation matches (${orientation})`, () => {
+ (useWindowDimensions as jest.Mock).mockImplementationOnce(() => ({
+ isPortrait: orientation === DeviceOrientation.PORTRAIT,
+ }));
+ const { result, unmount } = renderHook(useEnforceOrientation, { initialProps: orientation });
+
+ expect(result.current).toBe(false);
+
+ unmount();
+ });
+
+ it(`should return true when the orientation doesn't match (${orientation})`, () => {
+ (useWindowDimensions as jest.Mock).mockImplementationOnce(() => ({
+ isPortrait: orientation === DeviceOrientation.LANDSCAPE,
+ }));
+ const { result, unmount } = renderHook(useEnforceOrientation, { initialProps: orientation });
+
+ expect(result.current).toBe(true);
+
+ unmount();
+ });
+ });
+});
diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/useStartTasksOnComplete.test.ts b/packages/inspection-capture-web/test/hooks/useStartTasksOnComplete.test.ts
similarity index 89%
rename from packages/inspection-capture-web/test/PhotoCapture/hooks/useStartTasksOnComplete.test.ts
rename to packages/inspection-capture-web/test/hooks/useStartTasksOnComplete.test.ts
index 1ae7b10db..886862a71 100644
--- a/packages/inspection-capture-web/test/PhotoCapture/hooks/useStartTasksOnComplete.test.ts
+++ b/packages/inspection-capture-web/test/hooks/useStartTasksOnComplete.test.ts
@@ -6,10 +6,7 @@ import { useMonitoring } from '@monkvision/monitoring';
import { useMonkApi } from '@monkvision/network';
import { Sight, TaskName } from '@monkvision/types';
import { createFakePromise } from '@monkvision/test-utils';
-import {
- useStartTasksOnComplete,
- UseStartTasksOnCompleteParams,
-} from '../../../src/PhotoCapture/hooks';
+import { useStartTasksOnComplete, UseStartTasksOnCompleteParams } from '../../src/hooks';
function createParams(): UseStartTasksOnCompleteParams {
return {
@@ -237,4 +234,30 @@ describe('useStartTasksOnComplete hook', () => {
unmount();
});
+
+ it('should start the DD task and fill with the additional tasks if no sights are provided', () => {
+ const defaultProps = createParams();
+ const initialProps = {
+ ...defaultProps,
+ startTasksOnComplete: true,
+ sights: undefined,
+ tasksBySight: {
+ 'test-sight-1': [TaskName.DAMAGE_DETECTION, TaskName.PRICING, TaskName.IMAGE_EDITING],
+ 'test-sight-2': [TaskName.REPAIR_ESTIMATE],
+ },
+ additionalTasks: [TaskName.INSPECTION_PDF, TaskName.DASHBOARD_OCR],
+ };
+ const { result, unmount } = renderHook(useStartTasksOnComplete, { initialProps });
+
+ const startInspectionTasksMock = (useMonkApi as jest.Mock).mock.results[0].value
+ .startInspectionTasks;
+
+ result.current();
+ expect(startInspectionTasksMock).toHaveBeenCalledWith({
+ inspectionId: initialProps.inspectionId,
+ names: [TaskName.DAMAGE_DETECTION, TaskName.INSPECTION_PDF, TaskName.DASHBOARD_OCR],
+ });
+
+ unmount();
+ });
});
diff --git a/packages/network/src/api/image/requests.ts b/packages/network/src/api/image/requests.ts
index 682329e72..ea2c4d3f8 100644
--- a/packages/network/src/api/image/requests.ts
+++ b/packages/network/src/api/image/requests.ts
@@ -37,6 +37,10 @@ export enum ImageUploadType {
* Upload type corresponding to a video frame in the VideoCapture process.
*/
VIDEO_FRAME = 'video_frame',
+ /**
+ * Upload type corresponding to a manual photo in the VideoCapture process.
+ */
+ VIDEO_MANUAL_PHOTO = 'video_manual_photo',
}
/**
@@ -137,13 +141,31 @@ export interface AddVideoFrameOptions {
timestamp: number;
}
+/**
+ * Options specififed when adding a manual video photo to a VideoCapture inspection.
+ */
+export interface AddVideoManualPhotoOptions {
+ /**
+ * The type of the image upload : `ImageUploadType.VIDEO_MANUAL_PHOTO`;
+ */
+ uploadType: ImageUploadType.VIDEO_MANUAL_PHOTO;
+ /**
+ * The picture to add to the inspection.
+ */
+ picture: MonkPicture;
+ /**
+ * The ID of the inspection to add the video frame to.
+ */
+ inspectionId: string;
+}
/**
* Union type describing the different options that can be specified when adding a picture to an inspection.
*/
export type AddImageOptions =
| AddBeautyShotImageOptions
| Add2ShotCloseUpImageOptions
- | AddVideoFrameOptions;
+ | AddVideoFrameOptions
+ | AddVideoManualPhotoOptions;
interface AddImageData {
filename: string;
@@ -169,6 +191,14 @@ function getImageLabel(options: AddImageOptions): TranslationObject | undefined
nl: `Videoframe ${options.frameIndex}`,
};
}
+ if (options.uploadType === ImageUploadType.VIDEO_MANUAL_PHOTO) {
+ return {
+ en: `Video Manual Photo`,
+ fr: `Photo Manuelle Vidéo`,
+ de: `Foto Manuell Video`,
+ nl: `Foto-handleiding Video`,
+ };
+ }
return {
en: options.firstShot ? 'Close Up (part)' : 'Close Up (damage)',
fr: options.firstShot ? 'Photo Zoomée (partie)' : 'Photo Zoomée (dégât)',
@@ -273,6 +303,24 @@ function createVideoFrameData(options: AddVideoFrameOptions, filetype: string):
return { filename, body };
}
+function createVideoManualPhotoData(
+ options: AddVideoManualPhotoOptions,
+ filetype: string,
+): AddImageData {
+ const filename = `video-manual-photo-${Date.now()}.${filetype}`;
+
+ const body: ApiImagePost = {
+ acquisition: {
+ strategy: 'upload_multipart_form_keys',
+ file_key: MULTIPART_KEY_IMAGE,
+ },
+ tasks: [TaskName.DAMAGE_DETECTION],
+ additional_data: getAdditionalData(options),
+ };
+
+ return { filename, body };
+}
+
function getAddImageData(options: AddImageOptions, filetype: string): AddImageData {
switch (options.uploadType) {
case ImageUploadType.BEAUTY_SHOT:
@@ -281,6 +329,8 @@ function getAddImageData(options: AddImageOptions, filetype: string): AddImageDa
return createCloseUpImageData(options, filetype);
case ImageUploadType.VIDEO_FRAME:
return createVideoFrameData(options, filetype);
+ case ImageUploadType.VIDEO_MANUAL_PHOTO:
+ return createVideoManualPhotoData(options, filetype);
default:
throw new Error('Unknown image upload type.');
}
@@ -383,6 +433,7 @@ export async function addImage(
);
if (
options.uploadType !== ImageUploadType.VIDEO_FRAME &&
+ options.uploadType !== ImageUploadType.VIDEO_MANUAL_PHOTO &&
options.useThumbnailCaching !== false
) {
precacheThumbnail(image).catch((error) =>
diff --git a/packages/network/test/api/image/requests.test.ts b/packages/network/test/api/image/requests.test.ts
index a9a7eb38c..00d853622 100644
--- a/packages/network/test/api/image/requests.test.ts
+++ b/packages/network/test/api/image/requests.test.ts
@@ -19,6 +19,7 @@ import {
AddBeautyShotImageOptions,
addImage,
AddVideoFrameOptions,
+ AddVideoManualPhotoOptions,
ImageUploadType,
} from '../../../src/api/image';
import { mapApiImage } from '../../../src/api/image/mappers';
@@ -87,6 +88,20 @@ function createVideoFrameOptions(): AddVideoFrameOptions {
};
}
+function createVideoManualPhotoOptions(): AddVideoManualPhotoOptions {
+ return {
+ uploadType: ImageUploadType.VIDEO_MANUAL_PHOTO,
+ picture: {
+ blob: { size: 424 } as Blob,
+ uri: 'test-uri',
+ height: 720,
+ width: 1280,
+ mimetype: 'image/jpeg',
+ },
+ inspectionId: 'test-inspection-id',
+ };
+}
+
describe('Image requests', () => {
let fileMock: File;
let fileConstructorSpy: jest.SpyInstance;
@@ -348,6 +363,38 @@ describe('Image requests', () => {
);
});
+ it('should properly create the formdata for a video manual photo', async () => {
+ const options = createVideoManualPhotoOptions();
+ await addImage(options, apiConfig);
+
+ expect(ky.post).toHaveBeenCalled();
+ const formData = (ky.post as jest.Mock).mock.calls[0][1].body as FormData;
+ expect(typeof formData?.get('json')).toBe('string');
+ expect(JSON.parse(formData.get('json') as string)).toEqual({
+ acquisition: {
+ strategy: 'upload_multipart_form_keys',
+ file_key: 'image',
+ },
+ tasks: [TaskName.DAMAGE_DETECTION],
+ additional_data: {
+ label: {
+ en: `Video Manual Photo`,
+ fr: `Photo Manuelle Vidéo`,
+ de: `Foto Manuell Video`,
+ nl: `Foto-handleiding Video`,
+ },
+ created_at: expect.any(String),
+ },
+ });
+ expect(getFileExtensions).toHaveBeenCalledWith(options.picture.mimetype);
+ const filetype = (getFileExtensions as jest.Mock).mock.results[0].value[0];
+ expect(fileConstructorSpy).toHaveBeenCalledWith(
+ [options.picture.blob],
+ expect.stringMatching(new RegExp(`video-manual-photo-\\d{13}.${filetype}`)),
+ { type: filetype },
+ );
+ });
+
it('should properly set up the live compliance', async () => {
const options = createBeautyShotImageOptions();
options.compliance = { enableCompliance: true, useLiveCompliance: true };
diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts
index 6e06506fb..2867fe546 100644
--- a/packages/types/src/config.ts
+++ b/packages/types/src/config.ts
@@ -5,6 +5,20 @@ import { ComplianceOptions, TaskName } from './state';
import { DeviceOrientation } from './utils';
import { CreateInspectionOptions, MonkApiPermission } from './api';
+/**
+ * The types of insepction capture workflow.
+ */
+export enum CaptureWorkflow {
+ /**
+ * PhotoCapture workflow.
+ */
+ PHOTO = 'photo',
+ /**
+ * VideoCapture workflow.
+ */
+ VIDEO = 'video',
+}
+
/**
* Enumeration of the tutorial options.
*/
@@ -52,39 +66,93 @@ export type CameraConfig = Partial & {
};
/**
- * The configuration options for inspection capture applications.
+ * Shared config used by both PhotoCapture and VideoCapture apps.
*/
-export type CaptureAppConfig = CameraConfig &
+export type SharedCaptureAppConfig = CameraConfig & {
+ /**
+ * An optional list of additional tasks to run on every image of the inspection.
+ */
+ additionalTasks?: TaskName[];
+ /**
+ * Value indicating if tasks should be started at the end of the inspection :
+ * - If not provided or if value is set to `false`, no tasks will be started.
+ * - If set to `true`, for photo capture apps : the tasks described by the `tasksBySight` param (or, if not provided,
+ * the default tasks of each sight) will be started.
+ * - If set to `true`, for video capture apps : the default tasks of each sight (and also optionally the ones
+ * described by the `additionalTasks` param) will be started.
+ * - If an array of tasks is provided, the tasks started will be the ones contained in the array.
+ *
+ * @default true
+ */
+ startTasksOnComplete?: boolean | TaskName[];
+ /**
+ * Use this prop to enforce a specific device orientation for the Camera screen.
+ */
+ enforceOrientation?: DeviceOrientation;
+ /**
+ * Boolean indicating if manual login and logout in the app should be enabled or not.
+ */
+ allowManualLogin: boolean;
+ /**
+ * Boolean indicating if the application state (such as auth token, inspection ID etc.) should be fetched from the
+ * URL search params or not.
+ */
+ fetchFromSearchParams: boolean;
+ /**
+ * The API domain used to communicate with the API.
+ */
+ apiDomain: string;
+ /**
+ * The API domain used to communicate with the resize microservice.
+ */
+ thumbnailDomain: string;
+ /**
+ * Required API permissions to use the app.
+ */
+ requiredApiPermissions?: MonkApiPermission[];
+ /**
+ * Optional color palette to extend the default Monk palette.
+ */
+ palette?: Partial;
+} & (
+ | {
+ /**
+ * Boolean indicating if automatic inspection creation should be allowed or not.
+ */
+ allowCreateInspection: false;
+ }
+ | {
+ /**
+ * Boolean indicating if automatic inspection creation should be allowed or not.
+ */
+ allowCreateInspection: true;
+ /**
+ * Options used when automatically creating an inspection.
+ */
+ createInspectionOptions: CreateInspectionOptions;
+ }
+ );
+
+/**
+ * The configuration options for inspection capture applications using the PhotoCapture workflow.
+ */
+export type PhotoCaptureAppConfig = SharedCaptureAppConfig &
ComplianceOptions & {
/**
- * An optional list of additional tasks to run on every Sight of the inspection.
+ * The capture workflow of the capture app.
*/
- additionalTasks?: TaskName[];
+ workflow: CaptureWorkflow.PHOTO;
/**
* Record associating each sight with a list of tasks to execute for it. If not provided, the default tasks of the
* sight will be used.
*/
tasksBySight?: Record;
- /**
- * Value indicating if tasks should be started at the end of the inspection :
- * - If not provided or if value is set to `false`, no tasks will be started.
- * - If set to `true`, the tasks described by the `tasksBySight` param (or, if not provided, the default tasks of
- * each sight) will be started.
- * - If an array of tasks is provided, the tasks started will be the ones contained in the array.
- *
- * @default true
- */
- startTasksOnComplete?: boolean | TaskName[];
/**
* Boolean indicating if the close button should be displayed in the HUD on top of the Camera preview.
*
* @default false
*/
showCloseButton?: boolean;
- /**
- * Use this prop to enforce a specific device orientation for the Camera screen.
- */
- enforceOrientation?: DeviceOrientation;
/**
* A number indicating the maximum allowed duration in milliseconds for an upload before raising a "Bad Connection"
* warning to the user. Set this value to -1 to never show this warning to the user.
@@ -127,27 +195,10 @@ export type CaptureAppConfig = CameraConfig &
* The default vehicle type used if no vehicle type is defined.
*/
defaultVehicleType: VehicleType;
- /**
- * Boolean indicating if manual login and logout in the app should be enabled or not.
- */
- allowManualLogin: boolean;
/**
* Boolean indicating if vehicle type selection should be enabled if the vehicle type is not defined.
*/
allowVehicleTypeSelection: boolean;
- /**
- * Boolean indicating if the application state (such as auth token, inspection ID etc.) should be fetched from the
- * URL search params or not.
- */
- fetchFromSearchParams: boolean;
- /**
- * The API domain used to communicate with the API.
- */
- apiDomain: string;
- /**
- * The API domain used to communicate with the resize micro service.
- */
- thumbnailDomain: string;
/**
* Options for displaying the photo capture tutorial.
*
@@ -167,14 +218,6 @@ export type CaptureAppConfig = CameraConfig &
* @default true
*/
enableSightTutorial?: boolean;
- /**
- * Required API permissions to use the app.
- */
- requiredApiPermissions?: MonkApiPermission[];
- /**
- * Optional color palette to extend the default Monk palette.
- */
- palette?: Partial;
} & (
| {
/**
@@ -200,30 +243,60 @@ export type CaptureAppConfig = CameraConfig &
*/
sights: Record>>;
}
- ) &
- (
- | {
- /**
- * Boolean indicating if automatic inspection creation should be allowed or not.
- */
- allowCreateInspection: false;
- }
- | {
- /**
- * Boolean indicating if automatic inspection creation should be allowed or not.
- */
- allowCreateInspection: true;
- /**
- * Options used when automatically creating an inspection.
- */
- createInspectionOptions: CreateInspectionOptions;
- }
);
+/**
+ * The configuration options for inspection capture applications using the VideoCapture workflow.
+ */
+export type VideoCaptureAppConfig = SharedCaptureAppConfig & {
+ /**
+ * The capture workflow of the capture app.
+ */
+ workflow: CaptureWorkflow.VIDEO;
+ /**
+ * The minimum duration of a recording in milliseconds.
+ *
+ * @default 15000
+ */
+ minRecordingDuration?: number;
+ /**
+ * The maximum number of retries for failed image uploads.
+ *
+ * @default 3
+ */
+ maxRetryCount?: number;
+ /**
+ * Boolean indicating if a warning should be shown to the user when they are walking too fast around the vehicle.
+ *
+ * @default true
+ */
+ enableFastWalkingWarning?: boolean;
+ /**
+ * Boolean indicating if a warning should be shown to the user when they are shaking their phone too much.
+ *
+ * @default true
+ */
+ enablePhoneShakingWarning?: boolean;
+ /**
+ * The duration (in milliseconds) to wait between fast walking warnings. We recommend setting this value to at least
+ * 1000.
+ *
+ * @default 4000
+ */
+ fastWalkingWarningCooldown?: number;
+ /**
+ * The duration (in milliseconds) to wait between phone shaking warnings. We recommend setting this value to at least
+ * 1000.
+ *
+ * @default 4000
+ */
+ phoneShakingWarningCooldown?: number;
+};
+
/**
* Live configuration used to configure Monk apps on the go.
*/
-export type LiveConfig = CaptureAppConfig & {
+export type LiveConfig = (PhotoCaptureAppConfig | VideoCaptureAppConfig) & {
/**
* The ID of the live config, used to fetch it from the API.
*/
diff --git a/packages/types/src/utils.ts b/packages/types/src/utils.ts
index 6e63552ce..d19f45336 100644
--- a/packages/types/src/utils.ts
+++ b/packages/types/src/utils.ts
@@ -26,6 +26,24 @@ export enum DeviceOrientation {
LANDSCAPE = 'landscape',
}
+/**
+ * Object containing the current 3D rotation of a device on the 3 main rotation axis.
+ */
+export interface DeviceRotation {
+ /**
+ * The device orientation around the Z axis (yaw).
+ */
+ alpha: number;
+ /**
+ * The device orientation around the X axis (pitch).
+ */
+ beta: number;
+ /**
+ * The device orientation around the Y axis (roll).
+ */
+ gamma: number;
+}
+
/**
* Enumeration of the possible sort orders.
*/
diff --git a/yarn.lock b/yarn.lock
index c8fb4afe3..56a4a1166 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -15369,6 +15369,79 @@ __metadata:
languageName: node
linkType: hard
+"monk-demo-app-video@workspace:apps/demo-app-video":
+ version: 0.0.0-use.local
+ resolution: "monk-demo-app-video@workspace:apps/demo-app-video"
+ dependencies:
+ "@auth0/auth0-react": ^2.2.4
+ "@babel/core": ^7.22.9
+ "@monkvision/analytics": 4.5.6
+ "@monkvision/common": 4.5.6
+ "@monkvision/common-ui-web": 4.5.6
+ "@monkvision/eslint-config-base": 4.5.6
+ "@monkvision/eslint-config-typescript": 4.5.6
+ "@monkvision/eslint-config-typescript-react": 4.5.6
+ "@monkvision/inspection-capture-web": 4.5.6
+ "@monkvision/jest-config": 4.5.6
+ "@monkvision/monitoring": 4.5.6
+ "@monkvision/network": 4.5.6
+ "@monkvision/posthog": 4.5.6
+ "@monkvision/prettier-config": 4.5.6
+ "@monkvision/sentry": 4.5.6
+ "@monkvision/sights": 4.5.6
+ "@monkvision/test-utils": 4.5.6
+ "@monkvision/types": 4.5.6
+ "@monkvision/typescript-config": 4.5.6
+ "@testing-library/dom": ^8.20.0
+ "@testing-library/jest-dom": ^5.16.5
+ "@testing-library/react": ^12.1.5
+ "@testing-library/react-hooks": ^8.0.1
+ "@testing-library/user-event": ^12.1.5
+ "@types/babel__core": ^7
+ "@types/jest": ^27.5.2
+ "@types/node": ^16.18.18
+ "@types/react": ^17.0.2
+ "@types/react-dom": ^17.0.2
+ "@types/react-router-dom": ^5.3.3
+ "@types/sort-by": ^1
+ "@typescript-eslint/eslint-plugin": ^5.43.0
+ "@typescript-eslint/parser": ^5.43.0
+ axios: ^1.5.0
+ env-cmd: ^10.1.0
+ eslint: ^8.29.0
+ eslint-config-airbnb-base: ^15.0.0
+ eslint-config-prettier: ^8.5.0
+ eslint-formatter-pretty: ^4.1.0
+ eslint-plugin-eslint-comments: ^3.2.0
+ eslint-plugin-import: ^2.26.0
+ eslint-plugin-jest: ^25.3.0
+ eslint-plugin-jsx-a11y: ^6.7.1
+ eslint-plugin-prettier: ^4.2.1
+ eslint-plugin-promise: ^6.1.1
+ eslint-plugin-react: ^7.27.1
+ eslint-plugin-react-hooks: ^4.3.0
+ eslint-utils: ^3.0.0
+ i18next: ^23.4.5
+ i18next-browser-languagedetector: ^7.1.0
+ jest: ^29.3.1
+ jest-watch-typeahead: ^2.2.2
+ localforage: ^1.10.0
+ match-sorter: ^6.3.4
+ prettier: ^2.7.1
+ react: ^17.0.2
+ react-dom: ^17.0.2
+ react-i18next: ^13.2.0
+ react-router-dom: ^6.22.3
+ react-scripts: 5.0.1
+ regexpp: ^3.2.0
+ sort-by: ^1.2.0
+ source-map-explorer: ^2.5.3
+ ts-jest: ^29.0.3
+ typescript: ^4.9.5
+ web-vitals: ^2.1.4
+ languageName: unknown
+ linkType: soft
+
"monk-demo-app@workspace:apps/demo-app":
version: 0.0.0-use.local
resolution: "monk-demo-app@workspace:apps/demo-app"
@@ -15446,6 +15519,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "monk-documentation@workspace:documentation"
dependencies:
+ "@auth0/auth0-react": ^2.2.4
"@docusaurus/core": 2.4.3
"@docusaurus/module-type-aliases": 2.4.3
"@docusaurus/plugin-content-pages": ^2.4.3