From 83762e928ebee278e3972ff6876812bf3710bd3a Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Fri, 29 Nov 2024 11:05:45 +0100 Subject: [PATCH 01/28] Created fullColorSVG util function --- .../src/__mocks__/@monkvision/common.tsx | 2 + packages/common-ui-web/src/icons/Icon.tsx | 17 +------- packages/common-ui-web/src/icons/assets.ts | 2 + packages/common-ui-web/src/icons/names.ts | 1 + .../common-ui-web/test/icons/Icon.test.tsx | 40 ++++--------------- packages/common/README/UTILITIES.md | 16 ++++++++ packages/common/src/utils/color.utils.ts | 28 +++++++++++++ .../common/test/utils/color.utils.test.ts | 39 ++++++++++++++++++ 8 files changed, 97 insertions(+), 48 deletions(-) diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx index 72b341851..f44cf2b50 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx @@ -1,4 +1,5 @@ import { InteractiveStatus } from '@monkvision/types'; +import { fullyColorSVG } from '@monkvision/common/src'; function createMockLoadingState() { return { @@ -137,4 +138,5 @@ export = { isInputTouchedOrDirty: jest.fn(() => false), })), useIsMounted: jest.fn(() => jest.fn(() => true)), + fullyColorSVG: jest.fn(() => ({})), }; 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..a73382800 100644 --- a/packages/common-ui-web/src/icons/assets.ts +++ b/packages/common-ui-web/src/icons/assets.ts @@ -89,6 +89,8 @@ export const MonkIconAssetsMap: IconAssetsMap = { '', '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..f62ab6abf 100644 --- a/packages/common-ui-web/src/icons/names.ts +++ b/packages/common-ui-web/src/icons/names.ts @@ -45,6 +45,7 @@ export const iconNames = [ 'close', 'cloud-download', 'cloud-upload', + 'compass-outline', 'content-cut', 'convertible', 'copy', 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/UTILITIES.md b/packages/common/README/UTILITIES.md index 3d0effa52..ac7b3a8f2 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 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/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)); + }); + }); + }); }); From a9db63c5446a1418b10244acedb18ddbdec8f8ce Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Fri, 29 Nov 2024 21:42:17 +0100 Subject: [PATCH 02/28] Created useDeviceOrientation hook --- .../src/__mocks__/@monkvision/common.tsx | 7 +- packages/common/README/HOOKS.md | 11 ++ packages/common/src/hooks/index.ts | 1 + .../common/src/hooks/useDeviceOrientation.ts | 76 +++++++++++ .../test/hooks/useDeviceOrientation.test.ts | 124 ++++++++++++++++++ 5 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 packages/common/src/hooks/useDeviceOrientation.ts create mode 100644 packages/common/test/hooks/useDeviceOrientation.test.ts diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx index f44cf2b50..c29359840 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx @@ -1,5 +1,5 @@ import { InteractiveStatus } from '@monkvision/types'; -import { fullyColorSVG } from '@monkvision/common/src'; +import { fullyColorSVG, useDeviceOrientation } from '@monkvision/common/src'; function createMockLoadingState() { return { @@ -139,4 +139,9 @@ export = { })), useIsMounted: jest.fn(() => jest.fn(() => true)), fullyColorSVG: jest.fn(() => ({})), + useDeviceOrientation: jest.fn(() => ({ + isPermissionGranted: false, + alpha: 0, + requestCompassPermission: jest.fn(() => Promise.resolve()), + })), }; 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/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..c69b506f8 --- /dev/null +++ b/packages/common/src/hooks/useDeviceOrientation.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useObjectMemo } from './useObjectMemo'; + +enum DeviceOrientationPermissionResponse { + GRANTED = 'granted', + DENIED = 'denied', +} + +interface DeviceOrientationEventiOS extends DeviceOrientationEvent { + webkitCompassHeading?: number; + requestPermission?: () => Promise; +} + +/** + * 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 (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; +} + +/** + * Custom hook used to get the device orientation data using the embedded compass on the device. + */ +export function useDeviceOrientation(): DeviceOrientationHandle { + const [isPermissionGranted, setIsPermissionGranted] = useState(false); + const [alpha, setAlpha] = useState(0); + + const handleDeviceOrientationEvent = useCallback((event: DeviceOrientationEvent) => { + const alpha = (event as DeviceOrientationEventiOS).webkitCompassHeading ?? event.alpha ?? 0; + setAlpha(alpha); + }, []); + + 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({ isPermissionGranted, alpha, requestCompassPermission }); +} diff --git a/packages/common/test/hooks/useDeviceOrientation.test.ts b/packages/common/test/hooks/useDeviceOrientation.test.ts new file mode 100644 index 000000000..b34ea20e4 --- /dev/null +++ b/packages/common/test/hooks/useDeviceOrientation.test.ts @@ -0,0 +1,124 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { act, fireEvent } 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 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 Function; + 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 Function; + expect(result.current.alpha).toBe(0); + + const value = 2223; + act(() => eventHandler({ alpha: value })); + expect(result.current.alpha).toEqual(value); + + + unmount(); + }); +}); From c2e36b7d7c9cd4640c48e27ba331ede05a68bc4b Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Fri, 29 Nov 2024 23:00:56 +0100 Subject: [PATCH 03/28] Created useCameraPermission hook --- .../src/__mocks__/@monkvision/camera-web.tsx | 3 + packages/camera-web/README.md | 13 ++ .../src/Camera/hooks/useUserMedia.ts | 129 +++++++++--------- packages/camera-web/src/hooks/index.ts | 1 + .../src/hooks/useCameraPermission.ts | 37 +++++ packages/camera-web/src/index.ts | 1 + .../test/Camera/hooks/useUserMedia.test.ts | 49 ++++++- .../test/hooks/useCameraPermission.test.ts | 69 ++++++++++ 8 files changed, 238 insertions(+), 64 deletions(-) create mode 100644 packages/camera-web/src/hooks/index.ts create mode 100644 packages/camera-web/src/hooks/useCameraPermission.ts create mode 100644 packages/camera-web/test/hooks/useCameraPermission.test.ts 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/packages/camera-web/README.md b/packages/camera-web/README.md index ddc4af1ea..7a2b29b24 100644 --- a/packages/camera-web/README.md +++ b/packages/camera-web/README.md @@ -180,3 +180,16 @@ Object passed to Camera HUD components that is used to control the camera | 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/hooks/useUserMedia.ts b/packages/camera-web/src/Camera/hooks/useUserMedia.ts index 55e847b17..29fa735b9 100644 --- a/packages/camera-web/src/Camera/hooks/useUserMedia.ts +++ b/packages/camera-web/src/Camera/hooks/useUserMedia.ts @@ -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); @@ -208,17 +214,16 @@ export function useUserMedia( const { handleError } = useMonitoring(); const isActive = useRef(true); - 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; @@ -256,67 +261,66 @@ 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 (isActive.current) { + 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 && isActive.current) { + 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) { @@ -324,9 +328,10 @@ export function useUserMedia( } }; } - }, [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..726fba669 --- /dev/null +++ b/packages/camera-web/src/hooks/useCameraPermission.ts @@ -0,0 +1,37 @@ +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/hooks/useUserMedia.test.ts b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts index d18c6e1c0..62c6139a0 100644 --- a/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts +++ b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts @@ -18,10 +18,10 @@ import { createFakePromise } from '@monkvision/test-utils'; function renderUseUserMedia(initialProps: { constraints: MediaStreamConstraints; - videoRef: RefObject; + videoRef: RefObject | null; }) { return renderHook( - (props: { constraints: MediaStreamConstraints; videoRef: RefObject }) => + (props: { constraints: MediaStreamConstraints; videoRef: RefObject | null }) => useUserMedia(props.constraints, props.videoRef), { initialProps }, ); @@ -45,6 +45,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 +77,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 +113,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 +155,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 +182,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 +208,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 +229,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 +265,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 +300,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 +326,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(); + }); +}); From f16a03ac75d3c323510d79592328e08986443ffd Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Fri, 29 Nov 2024 23:17:39 +0100 Subject: [PATCH 04/28] Created VideoCapturePermissions component and initiated the VideoCapture project --- apps/demo-app-video/.env-cmdrc.json | 52 +++++ apps/demo-app-video/.eslintignore | 1 + apps/demo-app-video/.eslintrc.js | 14 ++ apps/demo-app-video/.gitignore | 26 +++ apps/demo-app-video/LICENSE | 32 +++ apps/demo-app-video/README.md | 74 ++++++ apps/demo-app-video/jest.config.js | 13 ++ apps/demo-app-video/package.json | 109 +++++++++ apps/demo-app-video/public/favicon.ico | Bin 0 -> 15086 bytes apps/demo-app-video/public/index.html | 20 ++ apps/demo-app-video/public/logo192.png | Bin 0 -> 9806 bytes apps/demo-app-video/public/logo512.png | Bin 0 -> 44238 bytes apps/demo-app-video/public/manifest.json | 25 ++ apps/demo-app-video/public/robots.txt | 3 + apps/demo-app-video/src/components/App.tsx | 32 +++ .../src/components/AppContainer.tsx | 22 ++ .../src/components/AppRouter.tsx | 52 +++++ apps/demo-app-video/src/components/index.ts | 3 + apps/demo-app-video/src/i18n.ts | 28 +++ apps/demo-app-video/src/index.css | 14 ++ apps/demo-app-video/src/index.tsx | 29 +++ apps/demo-app-video/src/local-config.json | 218 ++++++++++++++++++ .../CreateInspectionPage.module.css | 0 .../CreateInspectionPage.tsx | 16 ++ .../src/pages/CreateInspectionPage/index.ts | 1 + .../src/pages/LoginPage/LoginPage.module.css | 0 .../src/pages/LoginPage/LoginPage.tsx | 11 + .../src/pages/LoginPage/index.ts | 1 + .../VideoCapturePage.module.css | 12 + .../VideoCapturePage/VideoCapturePage.tsx | 13 ++ .../src/pages/VideoCapturePage/index.ts | 1 + apps/demo-app-video/src/pages/index.ts | 4 + apps/demo-app-video/src/pages/pages.ts | 5 + apps/demo-app-video/src/posthog.ts | 10 + apps/demo-app-video/src/react-app-env.d.ts | 1 + apps/demo-app-video/src/sentry.ts | 10 + apps/demo-app-video/src/setupTests.ts | 5 + apps/demo-app-video/src/translations/de.json | 1 + apps/demo-app-video/src/translations/en.json | 1 + apps/demo-app-video/src/translations/fr.json | 1 + apps/demo-app-video/src/translations/nl.json | 1 + apps/demo-app-video/tsconfig.build.json | 5 + apps/demo-app-video/tsconfig.json | 4 + apps/demo-app/package.json | 2 +- .../src/__mocks__/@monkvision/common.tsx | 1 - .../src/Camera/hooks/useUserMedia.ts | 4 +- .../src/hooks/useCameraPermission.ts | 9 +- .../test/Camera/hooks/useUserMedia.test.ts | 6 +- packages/common-ui-web/src/icons/assets.ts | 2 +- .../common/src/hooks/useDeviceOrientation.ts | 4 +- .../test/hooks/useDeviceOrientation.test.ts | 11 +- .../src/VideoCapture/VideoCapture.styles.ts | 8 + .../src/VideoCapture/VideoCapture.tsx | 29 +++ .../src/VideoCapture/VideoCaptureHOC.tsx | 35 +++ .../VideoCapturePermissions.styles.ts | 141 +++++++++++ .../VideoCapturePermissions.tsx | 89 +++++++ .../VideoCapturePermissions/index.ts | 4 + .../src/VideoCapture/index.ts | 2 + .../src/assets/logos.asset.ts | 2 + packages/inspection-capture-web/src/index.ts | 1 + .../src/translations/de.json | 14 ++ .../src/translations/en.json | 14 ++ .../src/translations/fr.json | 14 ++ .../src/translations/nl.json | 14 ++ .../VideoCapturePermissions.test.tsx | 101 ++++++++ yarn.lock | 73 ++++++ 66 files changed, 1433 insertions(+), 17 deletions(-) create mode 100644 apps/demo-app-video/.env-cmdrc.json create mode 100644 apps/demo-app-video/.eslintignore create mode 100644 apps/demo-app-video/.eslintrc.js create mode 100644 apps/demo-app-video/.gitignore create mode 100644 apps/demo-app-video/LICENSE create mode 100644 apps/demo-app-video/README.md create mode 100644 apps/demo-app-video/jest.config.js create mode 100644 apps/demo-app-video/package.json create mode 100644 apps/demo-app-video/public/favicon.ico create mode 100644 apps/demo-app-video/public/index.html create mode 100644 apps/demo-app-video/public/logo192.png create mode 100644 apps/demo-app-video/public/logo512.png create mode 100644 apps/demo-app-video/public/manifest.json create mode 100644 apps/demo-app-video/public/robots.txt create mode 100644 apps/demo-app-video/src/components/App.tsx create mode 100644 apps/demo-app-video/src/components/AppContainer.tsx create mode 100644 apps/demo-app-video/src/components/AppRouter.tsx create mode 100644 apps/demo-app-video/src/components/index.ts create mode 100644 apps/demo-app-video/src/i18n.ts create mode 100644 apps/demo-app-video/src/index.css create mode 100644 apps/demo-app-video/src/index.tsx create mode 100644 apps/demo-app-video/src/local-config.json create mode 100644 apps/demo-app-video/src/pages/CreateInspectionPage/CreateInspectionPage.module.css create mode 100644 apps/demo-app-video/src/pages/CreateInspectionPage/CreateInspectionPage.tsx create mode 100644 apps/demo-app-video/src/pages/CreateInspectionPage/index.ts create mode 100644 apps/demo-app-video/src/pages/LoginPage/LoginPage.module.css create mode 100644 apps/demo-app-video/src/pages/LoginPage/LoginPage.tsx create mode 100644 apps/demo-app-video/src/pages/LoginPage/index.ts create mode 100644 apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.module.css create mode 100644 apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx create mode 100644 apps/demo-app-video/src/pages/VideoCapturePage/index.ts create mode 100644 apps/demo-app-video/src/pages/index.ts create mode 100644 apps/demo-app-video/src/pages/pages.ts create mode 100644 apps/demo-app-video/src/posthog.ts create mode 100644 apps/demo-app-video/src/react-app-env.d.ts create mode 100644 apps/demo-app-video/src/sentry.ts create mode 100644 apps/demo-app-video/src/setupTests.ts create mode 100644 apps/demo-app-video/src/translations/de.json create mode 100644 apps/demo-app-video/src/translations/en.json create mode 100644 apps/demo-app-video/src/translations/fr.json create mode 100644 apps/demo-app-video/src/translations/nl.json create mode 100644 apps/demo-app-video/tsconfig.build.json create mode 100644 apps/demo-app-video/tsconfig.json create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCapture.styles.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHOC.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.styles.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/index.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/index.ts create mode 100644 packages/inspection-capture-web/src/assets/logos.asset.ts create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCapturePermissions.test.tsx diff --git a/apps/demo-app-video/.env-cmdrc.json b/apps/demo-app-video/.env-cmdrc.json new file mode 100644 index 000000000..0bd73dd99 --- /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-development", + "REACT_APP_USE_LOCAL_CONFIG": "true", + "REACT_APP_AUTH_DOMAIN": "idp.staging.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_CLIENT_ID": "DAeZWqeeOfgItYBcQzFeFwSrlvmUdN7L", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", + "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-development", + "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_CLIENT_ID": "O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", + "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai" + }, + "staging": { + "REACT_APP_ENVIRONMENT": "staging", + "REACT_APP_LIVE_CONFIG_ID": "demo-app-staging", + "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_CLIENT_ID": "O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", + "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai" + }, + "preview": { + "REACT_APP_ENVIRONMENT": "preview", + "REACT_APP_LIVE_CONFIG_ID": "demo-app-preview", + "REACT_APP_AUTH_DOMAIN": "idp.preview.monk.ai", + "REACT_APP_AUTH_AUDIENCE": "https://api.monk.ai/v1/", + "REACT_APP_AUTH_CLIENT_ID": "O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", + "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-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": "DAeZWqeeOfgItYBcQzFeFwSrlvmUdN7L", + "REACT_APP_SENTRY_DSN": "https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720", + "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..cf5a62e90 --- /dev/null +++ b/apps/demo-app-video/package.json @@ -0,0 +1,109 @@ +{ + "name": "monk-demo-app-video", + "version": "4.5.0", + "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.0", + "@monkvision/common": "4.5.0", + "@monkvision/common-ui-web": "4.5.0", + "@monkvision/inspection-capture-web": "4.5.0", + "@monkvision/monitoring": "4.5.0", + "@monkvision/network": "4.5.0", + "@monkvision/posthog": "4.5.0", + "@monkvision/sentry": "4.5.0", + "@monkvision/sights": "4.5.0", + "@monkvision/types": "4.5.0", + "@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.0", + "@monkvision/eslint-config-typescript": "4.5.0", + "@monkvision/eslint-config-typescript-react": "4.5.0", + "@monkvision/jest-config": "4.5.0", + "@monkvision/prettier-config": "4.5.0", + "@monkvision/test-utils": "4.5.0", + "@monkvision/typescript-config": "4.5.0", + "@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 0000000000000000000000000000000000000000..6e6a629b0a776a416796f25b11f1bcb332275af4 GIT binary patch literal 15086 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 + + + + + + + + + + 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 0000000000000000000000000000000000000000..0690e33a3b960a7aa27253d8653a6995e1f1738e GIT binary patch literal 9806 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%0AU8{7cYv(+ebNW4*Nn<;AcV9_gk$eN zC7mU5V*~pDt3KS+#iVKT>yjJj`d&zxhG8ACScBTpySEeZs%<6>|H$K{R? zTuGV>%Ct(>KxM3Nod!tNi7zI);)us2HdUayuFch{Pa&(@q=7QpK*+N zWOOOEC!IoFWvztR6@szp4)=tF0DOB4ro1M&lXRaIdh~;vj>MI}3+XuF1WN&+2zLu6 z309Ck`xxh)<=a4cs{)UEgL{BqztmECw3s)v?is~IceQur@UDPN2V5ld8+9T*y9yTp zM?Rsp0Jp)egZssn!e?05irL%Wtb6D}nrIeqT+1LZ%ld&} zCF!(kiluCDD~c$v!So^PJ9DgSz_p;}v6yr#GOo2bX#z5LrFW%zV(kvdih#lejd(cy z1}qi`q$gRsM5G{=bgC8Y0yxnS)zfSXm5D13TFRYeeMX{XbWn1z>7f%A0X&YS9Z0${ z@FMHlaE<80cj-o4@sE%m(mB~uHtY~}1s9Xfq@ZFtKroW@0T16yk z>oY(oJcP7ch6y9|ZYgU;fZsjM o`nyxC&%$ROZ|z2=Z#QV<|MYJ!ZfNl=6aWAK07*qoM6N<$f{i}DZU6uP literal 0 HcmV?d00001 diff --git a/apps/demo-app-video/public/logo512.png b/apps/demo-app-video/public/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..03813dd12c762c4a2b1cf2b33c3a1343af1fe14a GIT binary patch literal 44238 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 TeV;%N0zP_L|7pHgw~728)fz1- literal 0 HcmV?d00001 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..763f3573d --- /dev/null +++ b/apps/demo-app-video/src/components/App.tsx @@ -0,0 +1,32 @@ +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 { 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; + +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..c2b97a98e --- /dev/null +++ b/apps/demo-app-video/src/components/AppRouter.tsx @@ -0,0 +1,52 @@ +import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { Page, VideoCapturePage } from '../pages'; +import { App } from './App'; + +// export function AppRouter() { +// return ( +// +// +// }> +// } /> +// } /> +// } /> +// } /> +// +// +// +// } +// index +// /> +// +// +// +// } +// index +// /> +// } /> +// } /> +// +// +// +// ); +// } + +export function AppRouter() { + return ( + + + }> + } /> + } /> + } /> + + + + ); +} 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..8b93f50df --- /dev/null +++ b/apps/demo-app-video/src/index.css @@ -0,0 +1,14 @@ +html, +body, +#root, +.app-container { + height: 100dvh; + width: 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..cc5c3e70f --- /dev/null +++ b/apps/demo-app-video/src/local-config.json @@ -0,0 +1,218 @@ +{ + "id": "demo-app-dev", + "description": "Config for the dev Demo App.", + "allowSkipRetake": true, + "enableAddDamage": true, + "enableSightGuidelines": true, + "allowVehicleTypeSelection": true, + "allowManualLogin": true, + "fetchFromSearchParams": true, + "allowCreateInspection": true, + "createInspectionOptions": { + "tasks": ["damage_detection", "wheel_analysis"] + }, + "apiDomain": "api.staging.monk.ai/v1", + "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize", + "enableSightTutorial": false, + "startTasksOnComplete": true, + "showCloseButton": false, + "enforceOrientation": "landscape", + "maxUploadDurationWarning": 15000, + "useAdaptiveImageQuality": true, + "format": "image/jpeg", + "quality": 0.6, + "resolution": "4K", + "allowImageUpscaling": false, + "enableCompliance": true, + "useLiveCompliance": true, + "complianceIssues": [ + "blurriness", + "underexposure", + "overexposure", + "lens_flare", + "reflections", + "unknown_sight", + "unknown_viewpoint", + "no_vehicle", + "wrong_center_part", + "missing_parts", + "hidden_parts", + "missing" + ], + "complianceIssuesPerSight": { + "ff150-nF_oFvhI": [ + "blurriness", + "underexposure", + "overexposure", + "lens_flare", + "reflections", + "missing" + ] + }, + "defaultVehicleType": "cuv", + "enableSteeringWheelPosition": false, + "sights": { + "suv": [ + "jgc21-QIvfeg0X", + "jgc21-KyUUVU2P", + "jgc21-zCrDwYWE", + "jgc21-z15ZdJL6", + "jgc21-RE3li6rE", + "jgc21-omlus7Ui", + "jgc21-m2dDoMup", + "jgc21-3gjMwvQG", + "jgc21-ezXzTRkj", + "jgc21-tbF2Ax8v", + "jgc21-3JJvM7_B", + "jgc21-RAVpqaE4", + "jgc21-F-PPd4qN", + "jgc21-XXh8GWm8", + "jgc21-TRN9Des4", + "jgc21-s7WDTRmE", + "jgc21-__JKllz9" + ], + "cuv": [ + "fesc20-H1dfdfvH", + "fesc20-WMUaKDp1", + "fesc20-LTe3X2bg", + "fesc20-WIQsf_gX", + "fesc20-hp3Tk53x", + "fesc20-fOt832UV", + "fesc20-NLdqASzl", + "fesc20-4Wqx52oU", + "fesc20-dfICsfSV", + "fesc20-X8k7UFGf", + "fesc20-LZc7p2kK", + "fesc20-5Ts1UkPT", + "fesc20-gg1Xyrpu", + "fesc20-P0oSEh8p", + "fesc20-j3H8Z415", + "fesc20-dKVLig1i", + "fesc20-Wzdtgqqz" + ], + "sedan": [ + "haccord-8YjMcu0D", + "haccord-DUPnw5jj", + "haccord-hsCc_Nct", + "haccord-GQcZz48C", + "haccord-QKfhXU7o", + "haccord-mdZ7optI", + "haccord-bSAv3Hrj", + "haccord-W-Bn3bU1", + "haccord-GdWvsqrm", + "haccord-ps7cWy6K", + "haccord-Jq65fyD4", + "haccord-OXYy5gET", + "haccord-5LlCuIfL", + "haccord-Gtt0JNQl", + "haccord-cXSAj2ez", + "haccord-KN23XXkX", + "haccord-Z84erkMb" + ], + "hatchback": [ + "ffocus18-XlfgjQb9", + "ffocus18-3TiCVAaN", + "ffocus18-43ljK5xC", + "ffocus18-x_1SE7X-", + "ffocus18-QKfhXU7o", + "ffocus18-yo9eBDW6", + "ffocus18-cPUyM28L", + "ffocus18-S3kgFOBb", + "ffocus18-9MeSIqp7", + "ffocus18-X2LDjCvr", + "ffocus18-jWOq2CNN", + "ffocus18-P2jFq1Ea", + "ffocus18-U3Bcfc2Q", + "ffocus18-ts3buSD1", + "ffocus18-cXSAj2ez", + "ffocus18-KkeGvT-F", + "ffocus18-lRDlWiwR" + ], + "van": [ + "ftransit18-wyXf7MTv", + "ftransit18-UNAZWJ-r", + "ftransit18-5SiNC94w", + "ftransit18-Y0vPhBVF", + "ftransit18-xyp1rU0h", + "ftransit18-6khKhof0", + "ftransit18-eXJDDYmE", + "ftransit18-3Sbfx_KZ", + "ftransit18-iu1Vj2Oa", + "ftransit18-aA2K898S", + "ftransit18-NwBMLo3Z", + "ftransit18-cf0e-pcB", + "ftransit18-FFP5b34o", + "ftransit18-RJ2D7DNz", + "ftransit18-3fnjrISV", + "ftransit18-eztNpSRX", + "ftransit18-TkXihCj4", + "ftransit18-4NMPqEV6", + "ftransit18-IIVI_pnX" + ], + "minivan": [ + "tsienna20-YwrRNr9n", + "tsienna20-HykkFbXf", + "tsienna20-TI4TVvT9", + "tsienna20-65mfPdRD", + "tsienna20-Ia0SGJ6z", + "tsienna20-1LNxhgCR", + "tsienna20-U_FqYq-a", + "tsienna20-670P2H2V", + "tsienna20-1n_z8bYy", + "tsienna20-qA3aAUUq", + "tsienna20--a2RmRcs", + "tsienna20-SebsoqJm", + "tsienna20-u57qDaN_", + "tsienna20-Rw0Gtt7O", + "tsienna20-TibS83Qr", + "tsienna20-cI285Gon", + "tsienna20-KHB_Cd9k" + ], + "pickup": [ + "ff150-zXbg0l3z", + "ff150-3he9UOwy", + "ff150-KgHVkQBW", + "ff150-FqbrFVr2", + "ff150-g_xBOOS2", + "ff150-vwE3yqdh", + "ff150-V-xzfWsx", + "ff150-ouGGtRnf", + "ff150--xPZZd83", + "ff150-nF_oFvhI", + "ff150-t3KBMPeD", + "ff150-3rM9XB0Z", + "ff150-eOjyMInj", + "ff150-18YVVN-G", + "ff150-BmXfb-qD", + "ff150-gFp78fQO", + "ff150-7nvlys8r" + ] + }, + "sightGuidelines": [ + { + "en": "Kneel or bend low in front of the car, aligned with the grille. Centre the car in the frame, ensuring the front grille, headlights, and registration plate are fully visible. Include the top of the car and the bottom of the bumper in the shot.", + "fr": "Agenouillez-vous ou penchez-vous devant la voiture, aligné avec la calandre. Centrez la voiture dans le cadre, en vous assurant que la calandre avant, les phares et la plaque d'immatriculation soient bien visibles. Veillez à inclure le toit de la voiture et le bas du pare-chocs dans la photo.", + "nl": "Kniel of buk laag voor de auto, uitgelijnd met het rooster. Centreer de auto in het frame, zorg ervoor dat het voorrooster, de koplampen en het kenteken volledig zichtbaar zijn. Zorg ervoor dat de bovenkant van de auto en de onderkant van de bumper in de foto zichtbaar zijn.", + "de": "Knien oder beugen Sie sich vor dem Auto, ausgerichtet mit dem Kühlergrill. Zentrieren Sie das Auto im Bild, sodass der vordere Kühlergrill, die Scheinwerfer und das Nummernschild vollständig sichtbar sind. Achten Sie darauf, dass das Dach des Autos und der untere Teil der Stoßstange im Bild zu sehen sind.", + "sightIds": [ + "ffocus18-XlfgjQb9", + "jgc21-QIvfeg0X", + "ff150-zXbg0l3z", + "ftransit18-wyXf7MTv", + "tsienna20-YwrRNr9n", + "fesc20-H1dfdfvH", + "haccord-8YjMcu0D" + ] + } + ], + "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/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..5ed83f1a7 --- /dev/null +++ b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx @@ -0,0 +1,13 @@ +import { useTranslation } from 'react-i18next'; +import { VideoCapture } from '@monkvision/inspection-capture-web'; +import styles from './VideoCapturePage.module.css'; + +export function VideoCapturePage() { + const { i18n } = useTranslation(); + + 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/index.ts b/apps/demo-app-video/src/pages/index.ts new file mode 100644 index 000000000..f3dbb8f83 --- /dev/null +++ b/apps/demo-app-video/src/pages/index.ts @@ -0,0 +1,4 @@ +export * from './pages'; +export * from './LoginPage'; +export * from './CreateInspectionPage'; +export * from './VideoCapturePage'; 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..df093ac88 --- /dev/null +++ b/apps/demo-app-video/src/pages/pages.ts @@ -0,0 +1,5 @@ +export enum Page { + LOG_IN = '/log-in', + CREATE_INSPECTION = '/create-inspection', + VIDEO_CAPTURE = '/video-capture', +} 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..0967ef424 --- /dev/null +++ b/apps/demo-app-video/src/translations/de.json @@ -0,0 +1 @@ +{} 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..0967ef424 --- /dev/null +++ b/apps/demo-app-video/src/translations/en.json @@ -0,0 +1 @@ +{} 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..0967ef424 --- /dev/null +++ b/apps/demo-app-video/src/translations/fr.json @@ -0,0 +1 @@ +{} 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..0967ef424 --- /dev/null +++ b/apps/demo-app-video/src/translations/nl.json @@ -0,0 +1 @@ +{} 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/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx index c29359840..b0732b070 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx @@ -1,5 +1,4 @@ import { InteractiveStatus } from '@monkvision/types'; -import { fullyColorSVG, useDeviceOrientation } from '@monkvision/common/src'; function createMockLoadingState() { return { diff --git a/packages/camera-web/src/Camera/hooks/useUserMedia.ts b/packages/camera-web/src/Camera/hooks/useUserMedia.ts index 29fa735b9..a187cc656 100644 --- a/packages/camera-web/src/Camera/hooks/useUserMedia.ts +++ b/packages/camera-web/src/Camera/hooks/useUserMedia.ts @@ -100,7 +100,7 @@ export interface UserMediaResult { /** * The getUserMedia function that can be used to fetch the stream data manually if no videoRef is passed. */ - getUserMedia: () => Promise + getUserMedia: () => Promise; /** * The resulting video stream. The stream can be null when not initialized or in case of an error. */ @@ -314,7 +314,7 @@ export function useUserMedia( throw err; } } - } + }; effect().catch(handleError); } }, [constraints, stream, error, isLoading, lastConstraintsApplied, getUserMedia, videoRef]); diff --git a/packages/camera-web/src/hooks/useCameraPermission.ts b/packages/camera-web/src/hooks/useCameraPermission.ts index 726fba669..252721b14 100644 --- a/packages/camera-web/src/hooks/useCameraPermission.ts +++ b/packages/camera-web/src/hooks/useCameraPermission.ts @@ -20,10 +20,11 @@ export interface CameraPermissionHandle { */ export function useCameraPermission(): CameraPermissionHandle { const contraints = useMemo( - () => getMediaConstraints({ - resolution: isMobileDevice() ? CameraResolution.UHD_4K : CameraResolution.FHD_1080P, - facingMode: CameraFacingMode.ENVIRONMENT, - }), + () => + getMediaConstraints({ + resolution: isMobileDevice() ? CameraResolution.UHD_4K : CameraResolution.FHD_1080P, + facingMode: CameraFacingMode.ENVIRONMENT, + }), [], ); const { getUserMedia } = useUserMedia(contraints, null); diff --git a/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts index 62c6139a0..0b6f4b992 100644 --- a/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts +++ b/packages/camera-web/test/Camera/hooks/useUserMedia.test.ts @@ -21,8 +21,10 @@ function renderUseUserMedia(initialProps: { videoRef: RefObject | null; }) { return renderHook( - (props: { constraints: MediaStreamConstraints; videoRef: RefObject | null }) => - useUserMedia(props.constraints, props.videoRef), + (props: { + constraints: MediaStreamConstraints; + videoRef: RefObject | null; + }) => useUserMedia(props.constraints, props.videoRef), { initialProps }, ); } diff --git a/packages/common-ui-web/src/icons/assets.ts b/packages/common-ui-web/src/icons/assets.ts index a73382800..e2a680960 100644 --- a/packages/common-ui-web/src/icons/assets.ts +++ b/packages/common-ui-web/src/icons/assets.ts @@ -90,7 +90,7 @@ export const MonkIconAssetsMap: IconAssetsMap = { 'cloud-upload': '', 'compass-outline': - '', + '', 'content-cut': '', 'convertible': diff --git a/packages/common/src/hooks/useDeviceOrientation.ts b/packages/common/src/hooks/useDeviceOrientation.ts index c69b506f8..9769e2d7c 100644 --- a/packages/common/src/hooks/useDeviceOrientation.ts +++ b/packages/common/src/hooks/useDeviceOrientation.ts @@ -43,8 +43,8 @@ export function useDeviceOrientation(): DeviceOrientationHandle { const [alpha, setAlpha] = useState(0); const handleDeviceOrientationEvent = useCallback((event: DeviceOrientationEvent) => { - const alpha = (event as DeviceOrientationEventiOS).webkitCompassHeading ?? event.alpha ?? 0; - setAlpha(alpha); + const value = (event as DeviceOrientationEventiOS).webkitCompassHeading ?? event.alpha ?? 0; + setAlpha(value); }, []); const requestCompassPermission = useCallback(async () => { diff --git a/packages/common/test/hooks/useDeviceOrientation.test.ts b/packages/common/test/hooks/useDeviceOrientation.test.ts index b34ea20e4..81deb41ae 100644 --- a/packages/common/test/hooks/useDeviceOrientation.test.ts +++ b/packages/common/test/hooks/useDeviceOrientation.test.ts @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react-hooks'; -import { act, fireEvent } from '@testing-library/react'; +import { act } from '@testing-library/react'; import { useDeviceOrientation } from '../../src'; function useDefaultDeviceOrientationEvent(): void { @@ -92,7 +92,9 @@ describe('useDeviceOrientation hook', () => { await result.current.requestCompassPermission(); }); expect(spy).toHaveBeenCalledWith('deviceorientation', expect.any(Function)); - const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as Function; + const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as ( + event: any, + ) => void; expect(result.current.alpha).toBe(0); const value = 42; @@ -111,14 +113,15 @@ describe('useDeviceOrientation hook', () => { await result.current.requestCompassPermission(); }); expect(spy).toHaveBeenCalledWith('deviceorientation', expect.any(Function)); - const eventHandler = spy.mock.calls.find(([name]) => name === 'deviceorientation')?.[1] as 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(); }); }); 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..6871f37e6 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx @@ -0,0 +1,29 @@ +import { useI18nSync, useDeviceOrientation } from '@monkvision/common'; +import { styles } from './VideoCapture.styles'; +import { VideoCapturePermissions } from './VideoCapturePermissions'; + +/** + * Props of the VideoCapture component. + */ +export interface VideoCaptureProps { + /** + * The language to be used by this component. + * + * @default en + */ + lang?: string | null; +} + +// No ts-doc for this component : the component exported is VideoCaptureHOC +export function VideoCapture({ lang }: VideoCaptureProps) { + useI18nSync(lang); + const { requestCompassPermission } = useDeviceOrientation(); + return ( +
+ console.log('Success!')} + /> +
+ ); +} 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/VideoCapturePermissions/VideoCapturePermissions.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.styles.ts new file mode 100644 index 000000000..9ba109c4b --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.styles.ts @@ -0,0 +1,141 @@ +import { Styles } from '@monkvision/types'; +import { fullyColorSVG, useMonkTheme, useResponsiveStyle } from '@monkvision/common'; +import { DynamicSVGProps, IconProps } from '@monkvision/common-ui-web'; +import { CSSProperties, useCallback } from 'react'; + +export const styles: Styles = { + container: { + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }, + logo: { + margin: '32px 0', + width: 80, + height: 'auto', + }, + logoSmall: { + __media: { + maxHeight: 500, + }, + margin: '16px 0', + }, + title: { + fontSize: 32, + fontWeight: 700, + textAlign: 'center', + padding: '0 32px 16px 32px', + }, + titleSmall: { + __media: { + maxHeight: 500, + }, + fontSize: 24, + fontWeight: 700, + textAlign: 'center', + padding: '0 16px 10px 16px', + }, + permissionsContainer: { + flex: 1, + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + }, + permission: { + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + margin: 16, + }, + permissionIcon: { + marginRight: 12, + }, + permissionLabels: { + flex: 1, + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + }, + permissionTitle: { + fontSize: 20, + fontWeight: 500, + }, + permissionTitleSmall: { + __media: { + maxHeight: 500, + }, + fontSize: 16, + fontWeight: 500, + }, + permissionDescription: { + fontSize: 18, + paddingTop: 6, + opacity: 0.91, + fontWeight: 300, + }, + permissionDescriptionSmall: { + __media: { + maxHeight: 500, + }, + fontSize: 14, + fontWeight: 500, + }, + confirmContainer: { + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: '0 32px 32px 32px', + }, + confirmButton: { + alignSelf: 'stretch', + maxWidth: 300, + }, +}; + +interface VideoCapturePermissionsStyle { + logoProps: Partial; + permissionIconProps: Partial; + titleStyle: CSSProperties; + permissionTitleStyle: CSSProperties; + permissionDescriptionStyle: CSSProperties; +} + +export function useVideoCapturePermissionsStyles(): VideoCapturePermissionsStyle { + 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']), + }, + }, + permissionIconProps: { + size: 40, + primaryColor: palette.primary.base, + }, + titleStyle: { + ...styles['title'], + ...responsive(styles['titleSmall']), + }, + permissionTitleStyle: { + ...styles['permissionTitle'], + ...responsive(styles['permissionTitleSmall']), + }, + permissionDescriptionStyle: { + ...styles['permissionDescription'], + ...responsive(styles['permissionDescriptionSmall']), + }, + }; +} 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..dd8a61656 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx @@ -0,0 +1,89 @@ +import { Button, DynamicSVG, Icon } from '@monkvision/common-ui-web'; +import { useTranslation } from 'react-i18next'; +import { useLoadingState } from '@monkvision/common'; +import { useCameraPermission } from '@monkvision/camera-web'; +import { useMonitoring } from '@monkvision/monitoring'; +import { styles, useVideoCapturePermissionsStyles } from './VideoCapturePermissions.styles'; +import { monkLogoSVG } from '../../assets/logos.asset'; + +/** + * 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 { + logoProps, + permissionIconProps, + titleStyle, + permissionTitleStyle, + permissionDescriptionStyle, + } = useVideoCapturePermissionsStyles(); + + const handleConfirm = async () => { + loading.start(); + try { + await requestCameraPermission(); + if (requestCompassPermission) { + await requestCompassPermission(); + } + onSuccess?.(); + loading.onSuccess(); + } catch (err) { + loading.onError(err); + handleError(err); + } + }; + + return ( +
+ +
{t('video.permissions.title')}
+
+
+ +
+
{t('video.permissions.camera.title')}
+
+ {t('video.permissions.camera.description')} +
+
+
+
+ +
+
{t('video.permissions.compass.title')}
+
+ {t('video.permissions.compass.description')} +
+
+
+
+
+ +
+
+ ); +} 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/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/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..83cfa7500 100644 --- a/packages/inspection-capture-web/src/translations/de.json +++ b/packages/inspection-capture-web/src/translations/de.json @@ -44,5 +44,19 @@ "next": "Weiter" } } + }, + "video": { + "permissions": { + "title": "Aufzeichnung eines Videos zur Fahrzeugbegehung", + "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" + } } } diff --git a/packages/inspection-capture-web/src/translations/en.json b/packages/inspection-capture-web/src/translations/en.json index 601fcf592..db821500f 100644 --- a/packages/inspection-capture-web/src/translations/en.json +++ b/packages/inspection-capture-web/src/translations/en.json @@ -44,5 +44,19 @@ "next": "Next" } } + }, + "video": { + "permissions": { + "title": "Record a vehicle walkaround video", + "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" + } } } diff --git a/packages/inspection-capture-web/src/translations/fr.json b/packages/inspection-capture-web/src/translations/fr.json index 95e65564f..7ff898625 100644 --- a/packages/inspection-capture-web/src/translations/fr.json +++ b/packages/inspection-capture-web/src/translations/fr.json @@ -44,5 +44,19 @@ "next": "Suivant" } } + }, + "video": { + "permissions": { + "title": "Enregistrer une vidéo d'inspection du véhicule", + "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" + } } } diff --git a/packages/inspection-capture-web/src/translations/nl.json b/packages/inspection-capture-web/src/translations/nl.json index f593c59b0..75fcf28e5 100644 --- a/packages/inspection-capture-web/src/translations/nl.json +++ b/packages/inspection-capture-web/src/translations/nl.json @@ -44,5 +44,19 @@ "next": "Volgende" } } + }, + "video": { + "permissions": { + "title": "Een doorloopvideo van een voertuig opnemen", + "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" + } } } 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..b2d848bb7 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePermissions.test.tsx @@ -0,0 +1,101 @@ +import { render, waitFor } from '@testing-library/react'; +import { useCameraPermission } from '@monkvision/camera-web'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { Button } from '@monkvision/common-ui-web'; +import { VideoCapturePermissions } from '../../src/VideoCapture/VideoCapturePermissions'; + +describe('VideoCapturePermissions component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + 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(); + + expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + 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( + , + ); + + expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + 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( + , + ); + + expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + 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( + , + ); + + expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + onClick(); + + await waitFor(() => { + expect(onSuccess).not.toHaveBeenCalled(); + }); + + unmount(); + }); +}); diff --git a/yarn.lock b/yarn.lock index c8fb4afe3..ae5b11466 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.0 + "@monkvision/common": 4.5.0 + "@monkvision/common-ui-web": 4.5.0 + "@monkvision/eslint-config-base": 4.5.0 + "@monkvision/eslint-config-typescript": 4.5.0 + "@monkvision/eslint-config-typescript-react": 4.5.0 + "@monkvision/inspection-capture-web": 4.5.0 + "@monkvision/jest-config": 4.5.0 + "@monkvision/monitoring": 4.5.0 + "@monkvision/network": 4.5.0 + "@monkvision/posthog": 4.5.0 + "@monkvision/prettier-config": 4.5.0 + "@monkvision/sentry": 4.5.0 + "@monkvision/sights": 4.5.0 + "@monkvision/test-utils": 4.5.0 + "@monkvision/types": 4.5.0 + "@monkvision/typescript-config": 4.5.0 + "@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" From 7c89d3fc2b73b6ef3b656c8d4da35b7801594856 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Tue, 10 Dec 2024 11:58:55 +0100 Subject: [PATCH 05/28] Created VideoCaptureTutorial component --- apps/demo-app-video/package.json | 36 ++++---- .../src/Camera/hooks/useUserMedia.ts | 30 +++---- packages/common-ui-web/src/icons/assets.ts | 6 +- packages/common-ui-web/src/icons/names.ts | 2 + .../src/VideoCapture/VideoCapture.tsx | 21 ++++- .../VideoCaptureHUD/VideoCaptureHUD.styles.ts | 13 +++ .../VideoCaptureHUD/VideoCaptureHUD.tsx | 29 +++++++ .../VideoCaptureTutorial.tsx | 44 ++++++++++ .../VideoCaptureTutorial/index.ts | 1 + .../src/VideoCapture/VideoCaptureHUD/index.ts | 1 + .../IntroLayoutItem/IntroLayoutItem.styles.ts | 74 +++++++++++++++++ .../IntroLayoutItem/IntroLayoutItem.tsx | 37 +++++++++ .../IntroLayoutItem/index.ts | 1 + .../VideoCaptureIntroLayout.styles.ts} | 82 +++++-------------- .../VideoCaptureIntroLayout.tsx | 32 ++++++++ .../VideoCaptureIntroLayout.types.ts | 15 ++++ .../VideoCaptureIntroLayout/index.ts | 3 + .../VideoCapturePermissions.tsx | 65 ++++++--------- .../src/translations/de.json | 19 ++++- .../src/translations/en.json | 19 ++++- .../src/translations/fr.json | 19 ++++- .../src/translations/nl.json | 19 ++++- .../test/VideoCapture/VideoCapture.test.tsx | 9 ++ .../VideoCaptureHUD/VideoCaptureHUD.test.tsx | 9 ++ .../VideoCaptureTutorial.test.tsx | 75 +++++++++++++++++ .../IntroLayoutItem.test.tsx | 39 +++++++++ .../VideoCaptureIntroLayout.test.tsx | 68 +++++++++++++++ .../VideoCapturePermissions.test.tsx | 74 +++++++++++++++-- yarn.lock | 34 ++++---- 29 files changed, 703 insertions(+), 173 deletions(-) create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.styles.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/VideoCaptureTutorial.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/index.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/index.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts rename packages/inspection-capture-web/src/VideoCapture/{VideoCapturePermissions/VideoCapturePermissions.styles.ts => VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts} (52%) create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.types.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCapture.test.tsx create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial.test.tsx create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem.test.tsx create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.test.tsx diff --git a/apps/demo-app-video/package.json b/apps/demo-app-video/package.json index cf5a62e90..c06d186f9 100644 --- a/apps/demo-app-video/package.json +++ b/apps/demo-app-video/package.json @@ -1,6 +1,6 @@ { "name": "monk-demo-app-video", - "version": "4.5.0", + "version": "4.5.3", "license": "BSD-3-Clause-Clear", "packageManager": "yarn@3.2.4", "description": "MonkJs demo app for Video capture with React and TypeScript", @@ -25,16 +25,16 @@ }, "dependencies": { "@auth0/auth0-react": "^2.2.4", - "@monkvision/analytics": "4.5.0", - "@monkvision/common": "4.5.0", - "@monkvision/common-ui-web": "4.5.0", - "@monkvision/inspection-capture-web": "4.5.0", - "@monkvision/monitoring": "4.5.0", - "@monkvision/network": "4.5.0", - "@monkvision/posthog": "4.5.0", - "@monkvision/sentry": "4.5.0", - "@monkvision/sights": "4.5.0", - "@monkvision/types": "4.5.0", + "@monkvision/analytics": "4.5.3", + "@monkvision/common": "4.5.3", + "@monkvision/common-ui-web": "4.5.3", + "@monkvision/inspection-capture-web": "4.5.3", + "@monkvision/monitoring": "4.5.3", + "@monkvision/network": "4.5.3", + "@monkvision/posthog": "4.5.3", + "@monkvision/sentry": "4.5.3", + "@monkvision/sights": "4.5.3", + "@monkvision/types": "4.5.3", "@types/babel__core": "^7", "@types/jest": "^27.5.2", "@types/node": "^16.18.18", @@ -60,13 +60,13 @@ }, "devDependencies": { "@babel/core": "^7.22.9", - "@monkvision/eslint-config-base": "4.5.0", - "@monkvision/eslint-config-typescript": "4.5.0", - "@monkvision/eslint-config-typescript-react": "4.5.0", - "@monkvision/jest-config": "4.5.0", - "@monkvision/prettier-config": "4.5.0", - "@monkvision/test-utils": "4.5.0", - "@monkvision/typescript-config": "4.5.0", + "@monkvision/eslint-config-base": "4.5.3", + "@monkvision/eslint-config-typescript": "4.5.3", + "@monkvision/eslint-config-typescript-react": "4.5.3", + "@monkvision/jest-config": "4.5.3", + "@monkvision/prettier-config": "4.5.3", + "@monkvision/test-utils": "4.5.3", + "@monkvision/typescript-config": "4.5.3", "@testing-library/dom": "^8.20.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", diff --git a/packages/camera-web/src/Camera/hooks/useUserMedia.ts b/packages/camera-web/src/Camera/hooks/useUserMedia.ts index a187cc656..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'; /** @@ -212,13 +212,7 @@ export function useUserMedia( const [lastConstraintsApplied, setLastConstraintsApplied] = useState(null); const { handleError } = useMonitoring(); - const isActive = useRef(true); - - useEffect(() => { - return () => { - isActive.current = false; - }; - }, []); + const isMounted = useIsMounted(); const handleGetUserMediaError = (err: unknown, permissionState: PermissionState | null) => { let type = UserMediaErrorType.OTHER; @@ -245,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(() => { @@ -277,7 +273,7 @@ export function useUserMedia( }; const str = await navigator.mediaDevices.getUserMedia(updatedConstraints); str?.addEventListener('inactive', onStreamInactive); - if (isActive.current) { + if (isMounted()) { setStream(str); setDimensions(getStreamDimensions(str, true)); setIsLoading(false); @@ -309,7 +305,7 @@ export function useUserMedia( await getUserMedia(); } catch (err) { const permissionState = (await getCameraPermissionState())?.state ?? null; - if (err && isActive.current) { + if (err && isMounted()) { handleGetUserMediaError(err, permissionState); throw err; } @@ -323,7 +319,7 @@ export function useUserMedia( if (stream && videoRef && videoRef.current) { // eslint-disable-next-line no-param-reassign videoRef.current.onresize = () => { - if (isActive.current) { + if (isMounted()) { setDimensions(getStreamDimensions(stream, false)); } }; diff --git a/packages/common-ui-web/src/icons/assets.ts b/packages/common-ui-web/src/icons/assets.ts index e2a680960..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,6 +85,8 @@ export const MonkIconAssetsMap: IconAssetsMap = { '', 'circle': '', + 'circle-dot': + '', 'close': '', 'cloud-download': diff --git a/packages/common-ui-web/src/icons/names.ts b/packages/common-ui-web/src/icons/names.ts index f62ab6abf..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,6 +43,7 @@ export const iconNames = [ 'chevron-left', 'chevron-right', 'circle', + 'circle-dot', 'close', 'cloud-download', 'cloud-upload', diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx index 6871f37e6..7e28b39a8 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx @@ -1,6 +1,9 @@ import { useI18nSync, useDeviceOrientation } from '@monkvision/common'; +import { useState } from 'react'; +import { Camera } from '@monkvision/camera-web'; import { styles } from './VideoCapture.styles'; import { VideoCapturePermissions } from './VideoCapturePermissions'; +import { VideoCaptureHUD } from './VideoCaptureHUD'; /** * Props of the VideoCapture component. @@ -14,16 +17,26 @@ export interface VideoCaptureProps { lang?: string | null; } +enum VideoCaptureScreen { + PERMISSIONS = 'permissions', + CAPTURE = 'capture', +} + // No ts-doc for this component : the component exported is VideoCaptureHOC export function VideoCapture({ lang }: VideoCaptureProps) { useI18nSync(lang); + const [screen, setScreen] = useState(VideoCaptureScreen.PERMISSIONS); const { requestCompassPermission } = useDeviceOrientation(); + return (
- console.log('Success!')} - /> + {screen === VideoCaptureScreen.PERMISSIONS && ( + setScreen(VideoCaptureScreen.CAPTURE)} + /> + )} + {screen === VideoCaptureScreen.CAPTURE && }
); } 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..d2f3492d5 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react'; +import { CameraHUDProps } from '@monkvision/camera-web'; +import { styles } from './VideoCaptureHUD.styles'; +import { VideoCaptureTutorial } from './VideoCaptureTutorial'; + +/** + * Props accepted by the VideoCaptureHUD component. + */ +export interface VideoCaptureHUDProps extends CameraHUDProps {} + +/** + * HUD component displayed on top of the camera preview for the VideoCapture process. + */ +export function VideoCaptureHUD({ handle, cameraPreview }: VideoCaptureHUDProps) { + const [isTutorialDisplayed, setIsTutorialDisplayed] = useState(true); + + return ( +
+ {cameraPreview} +
+ {isTutorialDisplayed ? ( + setIsTutorialDisplayed(false)} /> + ) : ( + + )} +
+
+ ); +} 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..1b2b889c3 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial/VideoCaptureTutorial.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next'; +import { IntroLayoutItem, VideoCaptureIntroLayout } from '../../VideoCaptureIntroLayout'; + +/** + * 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/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts new file mode 100644 index 000000000..c9253dbf9 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts @@ -0,0 +1,74 @@ +import { CSSProperties } from 'react'; +import { Styles } from '@monkvision/types'; +import { useMonkTheme, useResponsiveStyle } from '@monkvision/common'; +import { IconProps } from '@monkvision/common-ui-web'; + +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: 500, + }, + fontSize: 16, + fontWeight: 500, + }, + description: { + fontSize: 18, + paddingTop: 6, + opacity: 0.91, + fontWeight: 300, + }, + descriptionSmall: { + __media: { + maxHeight: 500, + }, + fontSize: 14, + fontWeight: 500, + }, +}; + +interface IntroLayoutItemStyle { + iconProps: Partial; + titleStyle: CSSProperties; + descriptionStyle: CSSProperties; +} + +export function useIntroLayoutItemStyles(): IntroLayoutItemStyle { + const { palette } = useMonkTheme(); + const { responsive } = useResponsiveStyle(); + + return { + iconProps: { + size: 40, + primaryColor: palette.primary.base, + style: styles['icon'], + }, + titleStyle: { + ...styles['title'], + ...responsive(styles['titleSmall']), + }, + descriptionStyle: { + ...styles['description'], + ...responsive(styles['descriptionSmall']), + }, + }; +} diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx new file mode 100644 index 000000000..eb8c2a80a --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx @@ -0,0 +1,37 @@ +import { Icon, IconName } from '@monkvision/common-ui-web'; +import { styles, useIntroLayoutItemStyles } from './IntroLayoutItem.styles'; + +/** + * Props accepted by the IntroLayoutItem component. + */ +export interface IntroLayoutItemProps { + /** + * 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 IntroLayoutItem({ icon, title, description }: IntroLayoutItemProps) { + const { iconProps, titleStyle, descriptionStyle } = useIntroLayoutItemStyles(); + + return ( +
+ +
+
{title}
+
{description}
+
+
+ ); +} diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts new file mode 100644 index 000000000..98e0f1d0a --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts @@ -0,0 +1 @@ +export { IntroLayoutItem, type IntroLayoutItemProps } from './IntroLayoutItem'; diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts similarity index 52% rename from packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.styles.ts rename to packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts index 9ba109c4b..358f26ccd 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.styles.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts @@ -1,7 +1,10 @@ import { Styles } from '@monkvision/types'; -import { fullyColorSVG, useMonkTheme, useResponsiveStyle } from '@monkvision/common'; -import { DynamicSVGProps, IconProps } from '@monkvision/common-ui-web'; +import { DynamicSVGProps } from '@monkvision/common-ui-web'; import { CSSProperties, useCallback } from 'react'; +import { fullyColorSVG, useMonkTheme, useResponsiveStyle } from '@monkvision/common'; +import { VideoCaptureIntroLayoutProps } from './VideoCaptureIntroLayout.types'; + +const INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT = 500; export const styles: Styles = { container: { @@ -11,6 +14,9 @@ export const styles: Styles = { flexDirection: 'column', alignItems: 'center', }, + containerBackdrop: { + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, logo: { margin: '32px 0', width: 80, @@ -18,7 +24,7 @@ export const styles: Styles = { }, logoSmall: { __media: { - maxHeight: 500, + maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, margin: '16px 0', }, @@ -30,59 +36,19 @@ export const styles: Styles = { }, titleSmall: { __media: { - maxHeight: 500, + maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, fontSize: 24, fontWeight: 700, textAlign: 'center', padding: '0 16px 10px 16px', }, - permissionsContainer: { - flex: 1, - alignSelf: 'stretch', - display: 'flex', - flexDirection: 'column', - }, - permission: { - alignSelf: 'stretch', - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - margin: 16, - }, - permissionIcon: { - marginRight: 12, - }, - permissionLabels: { + childrenContainer: { flex: 1, alignSelf: 'stretch', display: 'flex', flexDirection: 'column', }, - permissionTitle: { - fontSize: 20, - fontWeight: 500, - }, - permissionTitleSmall: { - __media: { - maxHeight: 500, - }, - fontSize: 16, - fontWeight: 500, - }, - permissionDescription: { - fontSize: 18, - paddingTop: 6, - opacity: 0.91, - fontWeight: 300, - }, - permissionDescriptionSmall: { - __media: { - maxHeight: 500, - }, - fontSize: 14, - fontWeight: 500, - }, confirmContainer: { alignSelf: 'stretch', display: 'flex', @@ -92,19 +58,19 @@ export const styles: Styles = { }, confirmButton: { alignSelf: 'stretch', - maxWidth: 300, + maxWidth: 400, }, }; -interface VideoCapturePermissionsStyle { +interface VideoCaptureIntroLayoutStyles { logoProps: Partial; - permissionIconProps: Partial; + containerStyle: CSSProperties; titleStyle: CSSProperties; - permissionTitleStyle: CSSProperties; - permissionDescriptionStyle: CSSProperties; } -export function useVideoCapturePermissionsStyles(): VideoCapturePermissionsStyle { +export function useVideoCaptureIntroLayoutStyles({ + showBackdrop, +}: Pick): VideoCaptureIntroLayoutStyles { const { palette } = useMonkTheme(); const { responsive } = useResponsiveStyle(); @@ -121,21 +87,13 @@ export function useVideoCapturePermissionsStyles(): VideoCapturePermissionsStyle ...responsive(styles['logoSmall']), }, }, - permissionIconProps: { - size: 40, - primaryColor: palette.primary.base, + containerStyle: { + ...styles['container'], + ...(showBackdrop ? styles['containerBackdrop'] : {}), }, titleStyle: { ...styles['title'], ...responsive(styles['titleSmall']), }, - permissionTitleStyle: { - ...styles['permissionTitle'], - ...responsive(styles['permissionTitleSmall']), - }, - permissionDescriptionStyle: { - ...styles['permissionDescription'], - ...responsive(styles['permissionDescriptionSmall']), - }, }; } diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx new file mode 100644 index 000000000..970d1dc31 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx @@ -0,0 +1,32 @@ +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, useVideoCaptureIntroLayoutStyles } from './VideoCaptureIntroLayout.styles'; +import { VideoCaptureIntroLayoutProps } from './VideoCaptureIntroLayout.types'; + +/** + * This component is used to display the same layout for every "introduction" screen for the VideoCapture process (the + * premissions screen, the tutorial etc.). + */ +export function VideoCaptureIntroLayout({ + showBackdrop, + confirmButtonProps, + children, +}: PropsWithChildren) { + const { t } = useTranslation(); + const { logoProps, containerStyle, titleStyle } = useVideoCaptureIntroLayoutStyles({ + showBackdrop, + }); + + return ( +
+ +
{t('video.introduction.title')}
+
{children}
+
+
+
+ ); +} diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.types.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.types.ts new file mode 100644 index 000000000..ef7b2d2db --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.types.ts @@ -0,0 +1,15 @@ +import { ButtonProps } from '@monkvision/common-ui-web'; + +/** + * Props accepted by the VideoCaptureIntroLayout component. + */ +export interface VideoCaptureIntroLayoutProps { + /** + * Boolean indicating if a black backdrop should be displayed behind the component. + */ + showBackdrop?: boolean; + /** + * Pass-through props passed down to the confirm button. + */ + confirmButtonProps?: Partial; +} diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts new file mode 100644 index 000000000..f2c533299 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts @@ -0,0 +1,3 @@ +export { VideoCaptureIntroLayout } from './VideoCaptureIntroLayout'; +export { type VideoCaptureIntroLayoutProps } from './VideoCaptureIntroLayout.types'; +export { IntroLayoutItem, type IntroLayoutItemProps } from './IntroLayoutItem'; diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx index dd8a61656..4724925c2 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx @@ -1,10 +1,8 @@ -import { Button, DynamicSVG, Icon } from '@monkvision/common-ui-web'; import { useTranslation } from 'react-i18next'; -import { useLoadingState } from '@monkvision/common'; +import { useIsMounted, useLoadingState } from '@monkvision/common'; import { useCameraPermission } from '@monkvision/camera-web'; import { useMonitoring } from '@monkvision/monitoring'; -import { styles, useVideoCapturePermissionsStyles } from './VideoCapturePermissions.styles'; -import { monkLogoSVG } from '../../assets/logos.asset'; +import { IntroLayoutItem, VideoCaptureIntroLayout } from '../VideoCaptureIntroLayout'; /** * Props accepted by the VideoCapturePermissions component. @@ -32,13 +30,7 @@ export function VideoCapturePermissions({ const loading = useLoadingState(); const { handleError } = useMonitoring(); const { requestCameraPermission } = useCameraPermission(); - const { - logoProps, - permissionIconProps, - titleStyle, - permissionTitleStyle, - permissionDescriptionStyle, - } = useVideoCapturePermissionsStyles(); + const isMounted = useIsMounted(); const handleConfirm = async () => { loading.start(); @@ -48,42 +40,33 @@ export function VideoCapturePermissions({ await requestCompassPermission(); } onSuccess?.(); - loading.onSuccess(); + if (isMounted()) { + loading.onSuccess(); + } } catch (err) { loading.onError(err); handleError(err); } }; + const confirmButtonProps = { + onClick: handleConfirm, + loading, + children: t('video.permissions.confirm'), + }; + return ( -
- -
{t('video.permissions.title')}
-
-
- -
-
{t('video.permissions.camera.title')}
-
- {t('video.permissions.camera.description')} -
-
-
-
- -
-
{t('video.permissions.compass.title')}
-
- {t('video.permissions.compass.description')} -
-
-
-
-
- -
-
+ + + + ); } diff --git a/packages/inspection-capture-web/src/translations/de.json b/packages/inspection-capture-web/src/translations/de.json index 83cfa7500..637edb5e9 100644 --- a/packages/inspection-capture-web/src/translations/de.json +++ b/packages/inspection-capture-web/src/translations/de.json @@ -46,8 +46,10 @@ } }, "video": { + "introduction": { + "title": "Aufzeichnung eines Videos zur Fahrzeugbegehung" + }, "permissions": { - "title": "Aufzeichnung eines Videos zur Fahrzeugbegehung", "camera": { "title": "Kamera", "description": "Um Videos aufzunehmen, müssen Sie den Zugriff auf die Kamera des Geräts erlauben" @@ -57,6 +59,21 @@ "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" } } } diff --git a/packages/inspection-capture-web/src/translations/en.json b/packages/inspection-capture-web/src/translations/en.json index db821500f..75b8d2b0b 100644 --- a/packages/inspection-capture-web/src/translations/en.json +++ b/packages/inspection-capture-web/src/translations/en.json @@ -46,8 +46,10 @@ } }, "video": { + "introduction": { + "title": "Record a vehicle walkaround video" + }, "permissions": { - "title": "Record a vehicle walkaround video", "camera": { "title": "Camera", "description": "To record video, you need to allow access to the device's camera" @@ -57,6 +59,21 @@ "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" } } } diff --git a/packages/inspection-capture-web/src/translations/fr.json b/packages/inspection-capture-web/src/translations/fr.json index 7ff898625..1a58a2668 100644 --- a/packages/inspection-capture-web/src/translations/fr.json +++ b/packages/inspection-capture-web/src/translations/fr.json @@ -46,8 +46,10 @@ } }, "video": { + "introduction": { + "title": "Enregistrer une vidéo d'inspection du véhicule" + }, "permissions": { - "title": "Enregistrer une vidéo d'inspection du véhicule", "camera": { "title": "Caméra", "description": "Pour enregistrer une vidéo, vous devez autoriser l'accès à la caméra de l'appareil" @@ -57,6 +59,21 @@ "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" } } } diff --git a/packages/inspection-capture-web/src/translations/nl.json b/packages/inspection-capture-web/src/translations/nl.json index 75fcf28e5..f10a218f8 100644 --- a/packages/inspection-capture-web/src/translations/nl.json +++ b/packages/inspection-capture-web/src/translations/nl.json @@ -46,8 +46,10 @@ } }, "video": { + "introduction": { + "title": "Een doorloopvideo van een voertuig opnemen" + }, "permissions": { - "title": "Een doorloopvideo van een voertuig opnemen", "camera": { "title": "Camera", "description": "Om video's op te nemen, moet u toegang verlenen tot de camera van het apparaat" @@ -57,6 +59,21 @@ "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" } } } 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..405b20420 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCapture.test.tsx @@ -0,0 +1,9 @@ +import { VideoCapture } from '../../src'; + +describe('VideoCapture component', () => { + // TODO : Write the tests for this component when it is finished + + it('should be defined', () => { + expect(VideoCapture).toBeDefined(); + }); +}); 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..bce881230 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx @@ -0,0 +1,9 @@ +import { VideoCaptureHUD } from '../../../src/VideoCapture/VideoCaptureHUD'; + +describe('VideoCaptureHUD component', () => { + // TODO : Write the tests for this component when it is finished + + it('should be defined', () => { + expect(VideoCaptureHUD).toBeDefined(); + }); +}); 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..156ea93c4 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial.test.tsx @@ -0,0 +1,75 @@ +jest.mock('../../../src/VideoCapture/VideoCaptureIntroLayout', () => ({ + VideoCaptureIntroLayout: jest.fn(() => <>), + IntroLayoutItem: jest.fn(() => <>), +})); + +import { render } from '@testing-library/react'; +import { + IntroLayoutItem, + VideoCaptureIntroLayout, +} from '../../../src/VideoCapture/VideoCaptureIntroLayout'; +import { VideoCaptureTutorial } from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureTutorial'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; + +describe('VideoCaptureTutorial component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should use the VideoCaptureIntroLayout component for the layout', () => { + const { unmount } = render(); + + expectPropsOnChildMock(VideoCaptureIntroLayout, { showBackdrop: true }); + + unmount(); + }); + + it('should pass the onClose callback to the confirm button', () => { + const onClose = jest.fn(); + const { unmount } = render(); + + expectPropsOnChildMock(VideoCaptureIntroLayout, { + confirmButtonProps: { children: 'video.tutorial.confirm', onClick: expect.any(Function) }, + }); + const { onClick } = (VideoCaptureIntroLayout 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(IntroLayoutItem).not.toHaveBeenCalled(); + const { children } = (VideoCaptureIntroLayout 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(IntroLayoutItem, { + icon, + title, + description, + }); + }); + + unmount2(); + }); +}); diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem.test.tsx new file mode 100644 index 000000000..f256ef978 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { + IntroLayoutItem, + IntroLayoutItemProps, +} from '../../../src/VideoCapture/VideoCaptureIntroLayout'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { Icon } from '@monkvision/common-ui-web'; + +const props: IntroLayoutItemProps = { + icon: 'add-photo', + title: 'test-title-wow', + description: 'test description test test', +}; + +describe('IntroLayoutItem 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/VideoCaptureIntroLayout/VideoCaptureIntroLayout.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.test.tsx new file mode 100644 index 000000000..a1320e425 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.test.tsx @@ -0,0 +1,68 @@ +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 { VideoCaptureIntroLayout } from '../../../src/VideoCapture/VideoCaptureIntroLayout'; + +describe('VideoCaptureIntroLayout 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 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 index b2d848bb7..191240e23 100644 --- a/packages/inspection-capture-web/test/VideoCapture/VideoCapturePermissions.test.tsx +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCapturePermissions.test.tsx @@ -1,14 +1,42 @@ +jest.mock('../../src/VideoCapture/VideoCaptureIntroLayout', () => ({ + VideoCaptureIntroLayout: jest.fn(() => <>), + IntroLayoutItem: jest.fn(() => <>), +})); + import { render, waitFor } from '@testing-library/react'; import { useCameraPermission } from '@monkvision/camera-web'; import { expectPropsOnChildMock } from '@monkvision/test-utils'; -import { Button } from '@monkvision/common-ui-web'; import { VideoCapturePermissions } from '../../src/VideoCapture/VideoCapturePermissions'; +import { + IntroLayoutItem, + VideoCaptureIntroLayout, +} from '../../src/VideoCapture/VideoCaptureIntroLayout'; + +function expectVideoCaptureIntroLayoutConfirmButtonProps(): jest.Mock { + expectPropsOnChildMock(VideoCaptureIntroLayout, { + confirmButtonProps: { + children: 'video.permissions.confirm', + loading: expect.anything(), + onClick: expect.any(Function), + }, + }); + const { onClick } = (VideoCaptureIntroLayout as jest.Mock).mock.calls[0][0].confirmButtonProps; + return onClick; +} describe('VideoCapturePermissions component', () => { afterEach(() => { jest.clearAllMocks(); }); + it('should use the VideoCaptureIntroLayout component for the layout', () => { + const { unmount } = render(); + + expect(VideoCaptureIntroLayout).toHaveBeenCalled(); + + unmount(); + }); + it('should request compass and camera permissions when pressing on the button', async () => { const requestCompassPermission = jest.fn(() => Promise.resolve()); const { unmount } = render( @@ -21,8 +49,7 @@ describe('VideoCapturePermissions component', () => { expect(requestCompassPermission).not.toHaveBeenCalled(); expect(requestCameraPermission).not.toHaveBeenCalled(); - expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); - const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + const onClick = expectVideoCaptureIntroLayoutConfirmButtonProps(); onClick(); await waitFor(() => { @@ -43,8 +70,7 @@ describe('VideoCapturePermissions component', () => { />, ); - expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); - const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + const onClick = expectVideoCaptureIntroLayoutConfirmButtonProps(); onClick(); await waitFor(() => { @@ -64,8 +90,7 @@ describe('VideoCapturePermissions component', () => { />, ); - expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); - const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + const onClick = expectVideoCaptureIntroLayoutConfirmButtonProps(); onClick(); await waitFor(() => { @@ -88,8 +113,7 @@ describe('VideoCapturePermissions component', () => { />, ); - expectPropsOnChildMock(Button, { onClick: expect.any(Function) }); - const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + const onClick = expectVideoCaptureIntroLayoutConfirmButtonProps(); onClick(); await waitFor(() => { @@ -98,4 +122,36 @@ describe('VideoCapturePermissions component', () => { unmount(); }); + + it('should display an item for the camera permission', () => { + const { unmount } = render(); + unmount(); + + expect(IntroLayoutItem).not.toHaveBeenCalled(); + const { children } = (VideoCaptureIntroLayout as jest.Mock).mock.calls[0][0]; + const { unmount: unmount2 } = render(children); + expectPropsOnChildMock(IntroLayoutItem, { + 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(IntroLayoutItem).not.toHaveBeenCalled(); + const { children } = (VideoCaptureIntroLayout as jest.Mock).mock.calls[0][0]; + const { unmount: unmount2 } = render(children); + expectPropsOnChildMock(IntroLayoutItem, { + icon: 'compass-outline', + title: 'video.permissions.compass.title', + description: 'video.permissions.compass.description', + }); + + unmount2(); + }); }); diff --git a/yarn.lock b/yarn.lock index ae5b11466..e1122724d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15375,23 +15375,23 @@ __metadata: dependencies: "@auth0/auth0-react": ^2.2.4 "@babel/core": ^7.22.9 - "@monkvision/analytics": 4.5.0 - "@monkvision/common": 4.5.0 - "@monkvision/common-ui-web": 4.5.0 - "@monkvision/eslint-config-base": 4.5.0 - "@monkvision/eslint-config-typescript": 4.5.0 - "@monkvision/eslint-config-typescript-react": 4.5.0 - "@monkvision/inspection-capture-web": 4.5.0 - "@monkvision/jest-config": 4.5.0 - "@monkvision/monitoring": 4.5.0 - "@monkvision/network": 4.5.0 - "@monkvision/posthog": 4.5.0 - "@monkvision/prettier-config": 4.5.0 - "@monkvision/sentry": 4.5.0 - "@monkvision/sights": 4.5.0 - "@monkvision/test-utils": 4.5.0 - "@monkvision/types": 4.5.0 - "@monkvision/typescript-config": 4.5.0 + "@monkvision/analytics": 4.5.3 + "@monkvision/common": 4.5.3 + "@monkvision/common-ui-web": 4.5.3 + "@monkvision/eslint-config-base": 4.5.3 + "@monkvision/eslint-config-typescript": 4.5.3 + "@monkvision/eslint-config-typescript-react": 4.5.3 + "@monkvision/inspection-capture-web": 4.5.3 + "@monkvision/jest-config": 4.5.3 + "@monkvision/monitoring": 4.5.3 + "@monkvision/network": 4.5.3 + "@monkvision/posthog": 4.5.3 + "@monkvision/prettier-config": 4.5.3 + "@monkvision/sentry": 4.5.3 + "@monkvision/sights": 4.5.3 + "@monkvision/test-utils": 4.5.3 + "@monkvision/types": 4.5.3 + "@monkvision/typescript-config": 4.5.3 "@testing-library/dom": ^8.20.0 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^12.1.5 From dfe2885f6484016fcfa35d68490d7d8eebcd2f58 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Fri, 13 Dec 2024 10:55:55 +0100 Subject: [PATCH 06/28] Created RecordVideoButton component --- packages/common-ui-web/README.md | 23 +++ .../RecordVideoButton.styles.ts | 95 ++++++++++ .../RecordVideoButton/RecordVideoButton.tsx | 51 ++++++ .../RecordVideoButton.types.ts | 27 +++ .../src/components/RecordVideoButton/index.ts | 5 + .../TakePictureButton.styles.ts | 2 +- .../src/components/TakePictureButton/hooks.ts | 6 +- .../common-ui-web/src/components/index.ts | 1 + .../components/RecordVideoButton.test.tsx | 163 ++++++++++++++++++ 9 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts create mode 100644 packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.tsx create mode 100644 packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts create mode 100644 packages/common-ui-web/src/components/RecordVideoButton/index.ts create mode 100644 packages/common-ui-web/test/components/RecordVideoButton.test.tsx diff --git a/packages/common-ui-web/README.md b/packages/common-ui-web/README.md index 6382fc62d..860e5f35f 100644 --- a/packages/common-ui-web/README.md +++ b/packages/common-ui-web/README.md @@ -389,6 +389,29 @@ 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` | + +--- + ## SightOverlay ### Description A component that displays the SVG overlay of the given sight. The SVG element can be customized the exact same way as 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..464c2b010 --- /dev/null +++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts @@ -0,0 +1,95 @@ +import { InteractiveStatus, Styles } from '@monkvision/types'; +import { CSSProperties, useState } from 'react'; +import { + getInteractiveVariants, + InteractiveVariation, + useInterval, + useIsMounted, +} 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 RECORDING_ANIMATION_DURATION_MS = 1200; + +export const styles: Styles = { + button: { + borderStyle: 'solid', + borderRadius: '50%', + cursor: 'pointer', + boxSizing: 'border-box', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + buttonDisabled: { + opacity: 0.75, + cursor: 'default', + }, + innerCircle: { + borderRadius: '50%', + }, +}; + +interface RecordVideoButtonStyleParams + extends Required> { + status: InteractiveStatus; +} + +interface RecordVideoButtonStyles { + container: CSSProperties; + innerCircle: CSSProperties; +} + +export function useRecordVideoButtonStyles({ + size, + isRecording, + status, +}: RecordVideoButtonStyleParams): RecordVideoButtonStyles { + 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 innerCircleSize = (isRecording ? animationRatio : INNER_CIRCLE_DEFAULT_RATIO) * size; + const innerCircleBackgroundColor = isRecording + ? RECORD_VIDEO_BUTTON_RECORDING_COLORS[status] + : TAKE_PICTURE_BUTTON_COLORS[status]; + + 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` + : '', + }, + }; +} 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..8168591df --- /dev/null +++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.tsx @@ -0,0 +1,51 @@ +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, + style = {}, + onClick, + onMouseEnter, + onMouseLeave, + onMouseDown, + onMouseUp, + ...passThroughProps + }, + ref, + ) { + const { status, eventHandlers } = useInteractiveStatus({ + disabled, + componentHandlers: { + onMouseEnter, + onMouseLeave, + onMouseDown, + onMouseUp, + }, + }); + const { container, innerCircle } = useRecordVideoButtonStyles({ size, isRecording, status }); + + 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..684a44ad9 --- /dev/null +++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts @@ -0,0 +1,27 @@ +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; + /** + * 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/index.ts b/packages/common-ui-web/src/components/index.ts index d983ad7b6..8aa57524e 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'; 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..c0bae5715 --- /dev/null +++ b/packages/common-ui-web/test/components/RecordVideoButton.test.tsx @@ -0,0 +1,163 @@ +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 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, + ); + }); +}); From 4e34f630dc83e6af17e222c7025acf8d38b90220 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Fri, 13 Dec 2024 16:21:20 +0100 Subject: [PATCH 07/28] Created VehicleWalkaroundIndicator component --- packages/common-ui-web/README.md | 55 +++++--- .../VehicleWalkaroundIndicator.styles.ts | 122 ++++++++++++++++++ .../VehicleWalkaroundIndicator.tsx | 23 ++++ .../VehicleWalkaroundIndicator.types.ts | 15 +++ .../VehicleWalkaroundIndicator/index.ts | 2 + .../common-ui-web/src/components/index.ts | 1 + .../VehicleWalkaroundIndicator.test.tsx | 61 +++++++++ 7 files changed, 263 insertions(+), 16 deletions(-) create mode 100644 packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.styles.ts create mode 100644 packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.tsx create mode 100644 packages/common-ui-web/src/components/VehicleWalkaroundIndicator/VehicleWalkaroundIndicator.types.ts create mode 100644 packages/common-ui-web/src/components/VehicleWalkaroundIndicator/index.ts create mode 100644 packages/common-ui-web/test/components/VehicleWalkaroundIndicator.test.tsx diff --git a/packages/common-ui-web/README.md b/packages/common-ui-web/README.md index 860e5f35f..f28a42340 100644 --- a/packages/common-ui-web/README.md +++ b/packages/common-ui-web/README.md @@ -647,22 +647,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. @@ -698,3 +684,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/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 ( + + {steps.map(({ alpha: stepAlpha, props }) => ( + + ))} + + + + ); +} 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 8aa57524e..59784da71 100644 --- a/packages/common-ui-web/src/components/index.ts +++ b/packages/common-ui-web/src/components/index.ts @@ -19,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/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(); + }); +}); From 32aa62c759bbd85a4e08f0ea652193cb069c2bf5 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Mon, 16 Dec 2024 13:11:20 +0100 Subject: [PATCH 08/28] Created VideoCaptureRecording component --- apps/demo-app-video/src/index.css | 2 + .../__mocks__/@monkvision/common-ui-web.tsx | 11 ++ .../src/VideoCapture/VideoCapture.tsx | 8 +- .../VideoCaptureHUD/VideoCaptureHUD.tsx | 31 ++++- .../VideoCaptureRecording.tsx | 38 ++++++ .../VideoCaptureRecording.types.ts | 21 +++ .../VideoCaptureRecordingStyles.ts | 86 ++++++++++++ .../VideoCaptureRecording/index.ts | 2 + .../IntroLayoutItem/IntroLayoutItem.styles.ts | 17 ++- .../VideoCaptureIntroLayout.styles.ts | 9 +- .../VideoCapturePermissions.tsx | 2 +- .../src/VideoCapture/hooks/index.ts | 1 + .../hooks/useVehicleWalkaround.ts | 43 ++++++ .../VideoCaptureRecording.test.tsx | 122 ++++++++++++++++++ .../hooks/useVehicleWalkaround.test.ts | 78 +++++++++++ 15 files changed, 454 insertions(+), 17 deletions(-) create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/index.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/hooks/index.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx create mode 100644 packages/inspection-capture-web/test/VideoCapture/hooks/useVehicleWalkaround.test.ts diff --git a/apps/demo-app-video/src/index.css b/apps/demo-app-video/src/index.css index 8b93f50df..404c8fcb4 100644 --- a/apps/demo-app-video/src/index.css +++ b/apps/demo-app-video/src/index.css @@ -4,6 +4,8 @@ body, .app-container { height: 100dvh; width: 100%; + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; } body { 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/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx index 7e28b39a8..bc0fbc56c 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx @@ -26,7 +26,9 @@ enum VideoCaptureScreen { export function VideoCapture({ lang }: VideoCaptureProps) { useI18nSync(lang); const [screen, setScreen] = useState(VideoCaptureScreen.PERMISSIONS); - const { requestCompassPermission } = useDeviceOrientation(); + const { requestCompassPermission, alpha } = useDeviceOrientation(); + + const hudProps = { alpha }; return (
@@ -36,7 +38,9 @@ export function VideoCapture({ lang }: VideoCaptureProps) { onSuccess={() => setScreen(VideoCaptureScreen.CAPTURE)} /> )} - {screen === VideoCaptureScreen.CAPTURE && } + {screen === VideoCaptureScreen.CAPTURE && ( + + )}
); } diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx index d2f3492d5..30a5c7fca 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx @@ -2,17 +2,37 @@ import { useState } from 'react'; import { CameraHUDProps } from '@monkvision/camera-web'; import { styles } from './VideoCaptureHUD.styles'; import { VideoCaptureTutorial } from './VideoCaptureTutorial'; +import { VideoCaptureRecording } from './VideoCaptureRecording'; +import { useVehicleWalkaround } from '../hooks'; /** * Props accepted by the VideoCaptureHUD component. */ -export interface VideoCaptureHUDProps extends CameraHUDProps {} +export interface VideoCaptureHUDProps extends CameraHUDProps { + /** + * The alpha value of the device orientaiton. + */ + alpha: number; +} /** * HUD component displayed on top of the camera preview for the VideoCapture process. */ -export function VideoCaptureHUD({ handle, cameraPreview }: VideoCaptureHUDProps) { +export function VideoCaptureHUD({ handle, cameraPreview, alpha }: VideoCaptureHUDProps) { const [isTutorialDisplayed, setIsTutorialDisplayed] = useState(true); + const [isRecording, setIsRecording] = useState(false); + const { walkaroundPosition, startWalkaround } = useVehicleWalkaround({ alpha }); + + const onClickRecordVideo = () => { + if (isRecording) { + setIsRecording(false); + } else { + startWalkaround(); + setIsRecording(true); + } + }; + + const onClickTakePicture = () => {}; return (
@@ -21,7 +41,12 @@ export function VideoCaptureHUD({ handle, cameraPreview }: VideoCaptureHUDProps) {isTutorialDisplayed ? ( setIsTutorialDisplayed(false)} /> ) : ( - + )}
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..f7742c1fe --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx @@ -0,0 +1,38 @@ +import { + RecordVideoButton, + TakePictureButton, + VehicleWalkaroundIndicator, +} from '@monkvision/common-ui-web'; +import { useVideoCaptureRecordingStyles } from './VideoCaptureRecordingStyles'; +import { VideoCaptureRecordingProps } from './VideoCaptureRecording.types'; + +/** + * HUD used in recording mode displayed on top of the camera in the VideoCaputre process. + */ +export function VideoCaptureRecording({ + walkaroundPosition, + isRecording, + onClickRecordVideo, + onClickTakePicture, +}: VideoCaptureRecordingProps) { + const { container, controls, takePictureFlash, walkaroundIndicator, showTakePictureFlash } = + useVideoCaptureRecordingStyles({ isRecording }); + + const handleTakePictureClick = () => { + showTakePictureFlash(); + onClickTakePicture?.(); + }; + + return ( +
+
+
+ +
+ + +
+
+
+ ); +} 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..d4a83b7dc --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts @@ -0,0 +1,21 @@ +/** + * 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; + /** + * 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; +} 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..685d556b0 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts @@ -0,0 +1,86 @@ +import { Styles } from '@monkvision/types'; +import { useIsMounted, useResponsiveStyle } from '@monkvision/common'; +import { useState } from 'react'; +import { VideoCaptureRecordingProps } from './VideoCaptureRecording.types'; + +export const styles: Styles = { + container: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'flex-end', + alignSelf: 'stretch', + }, + containerLandscape: { + __media: { landscape: true }, + flexDirection: 'row', + }, + 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 { responsive } = useResponsiveStyle(); + const isMounted = useIsMounted(); + + const showTakePictureFlash = () => { + setTakePictureFlashVisible(true); + setTimeout(() => { + if (isMounted()) { + setTakePictureFlashVisible(false); + } + }, 100); + }; + + return { + container: { + ...styles['container'], + ...responsive(styles['containerLandscape']), + }, + controls: { + ...styles['controls'], + ...responsive(styles['controlsLandscape']), + }, + takePictureFlash: { + ...styles['takePictureFlash'], + ...(isTakePictureFlashVisible ? styles['takePictureFlashVisible'] : {}), + }, + walkaroundIndicator: { + ...(isRecording ? {} : styles['walkaroundIndicatorDisabled']), + }, + showTakePictureFlash, + }; +} 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/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts index c9253dbf9..ea7b691ce 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts @@ -3,6 +3,8 @@ import { Styles } from '@monkvision/types'; import { useMonkTheme, useResponsiveStyle } from '@monkvision/common'; import { IconProps } from '@monkvision/common-ui-web'; +export const INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT = 600; + export const styles: Styles = { container: { alignSelf: 'stretch', @@ -26,9 +28,9 @@ export const styles: Styles = { }, titleSmall: { __media: { - maxHeight: 500, + maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, - fontSize: 16, + fontSize: 14, fontWeight: 500, }, description: { @@ -39,10 +41,10 @@ export const styles: Styles = { }, descriptionSmall: { __media: { - maxHeight: 500, + maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, - fontSize: 14, - fontWeight: 500, + fontSize: 12, + fontWeight: 400, }, }; @@ -60,7 +62,10 @@ export function useIntroLayoutItemStyles(): IntroLayoutItemStyle { iconProps: { size: 40, primaryColor: palette.primary.base, - style: styles['icon'], + style: { + ...styles['icon'], + ...responsive(styles['iconSmall']), + }, }, titleStyle: { ...styles['title'], diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts index 358f26ccd..ea8642550 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts @@ -3,8 +3,7 @@ import { DynamicSVGProps } from '@monkvision/common-ui-web'; import { CSSProperties, useCallback } from 'react'; import { fullyColorSVG, useMonkTheme, useResponsiveStyle } from '@monkvision/common'; import { VideoCaptureIntroLayoutProps } from './VideoCaptureIntroLayout.types'; - -const INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT = 500; +import { INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT } from './IntroLayoutItem/IntroLayoutItem.styles'; export const styles: Styles = { container: { @@ -26,7 +25,7 @@ export const styles: Styles = { __media: { maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, - margin: '16px 0', + display: 'none', }, title: { fontSize: 32, @@ -38,10 +37,10 @@ export const styles: Styles = { __media: { maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, - fontSize: 24, + fontSize: 20, fontWeight: 700, textAlign: 'center', - padding: '0 16px 10px 16px', + padding: '10px 16px 10px 16px', }, childrenContainer: { flex: 1, diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx index 4724925c2..ddc38a611 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePermissions/VideoCapturePermissions.tsx @@ -35,10 +35,10 @@ export function VideoCapturePermissions({ const handleConfirm = async () => { loading.start(); try { - await requestCameraPermission(); if (requestCompassPermission) { await requestCompassPermission(); } + await requestCameraPermission(); onSuccess?.(); if (isMounted()) { loading.onSuccess(); 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..19e2e9457 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts @@ -0,0 +1 @@ +export * from './useVehicleWalkaround'; 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..85d98ac3a --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts @@ -0,0 +1,43 @@ +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; +} + +/** + * Custom hook used to manage the vehicle walkaround tracking. + */ +export function useVehicleWalkaround({ alpha }: UseVehicleWalkaroundParams) { + 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; + return position <= nextCheckpoint ? position : 0; + }, [startingAlpha, alpha, nextCheckpoint]); + + const startWalkaround = useCallback(() => { + setStartingAlpha(alpha); + }, [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/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx new file mode 100644 index 000000000..3ee6c069d --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx @@ -0,0 +1,122 @@ +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 } from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording'; + +const VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID = 'walkaround-indicator-container'; + +describe('VideoCaptureRecording component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should display the VehicleWalkaroundIndicator component', () => { + const walkaroundPosition = 344.7; + const { unmount } = render( + , + ); + + expectPropsOnChildMock(VehicleWalkaroundIndicator, { alpha: walkaroundPosition }); + + unmount(); + }); + + it('should change the style of the VehicleWalkaroundIndicator component when not recording', () => { + const disabledStyle = { + filter: 'grayscale(1)', + opacity: 0.7, + }; + const { rerender, unmount } = render( + , + ); + expect(screen.queryByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).toHaveStyle( + disabledStyle, + ); + + rerender(); + expect(screen.queryByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).not.toHaveStyle( + disabledStyle, + ); + + unmount(); + }); + + it('should display the RecordVideoButton and pass it the recording state', () => { + 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 onClickRecordVideo = jest.fn(); + const { unmount } = render( + , + ); + + expectPropsOnChildMock(RecordVideoButton, { onClick: expect.any(Function) }); + const { onClick } = (RecordVideoButton as unknown as jest.Mock).mock.calls[0][0]; + expect(onClickRecordVideo).not.toHaveBeenCalled(); + act(() => { + onClick(); + }); + expect(onClickRecordVideo).toHaveBeenCalled(); + + unmount(); + }); + + it('should display the TakePictureButton', () => { + const { unmount } = render(); + + expect(TakePictureButton).toHaveBeenCalled(); + + unmount(); + }); + + it('should disable the TakePictureButton when not recording', () => { + 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 onClickTakePicture = jest.fn(); + const { unmount } = render( + , + ); + + expectPropsOnChildMock(TakePictureButton, { onClick: expect.any(Function) }); + const { onClick } = (TakePictureButton as unknown as jest.Mock).mock.calls[0][0]; + expect(onClickTakePicture).not.toHaveBeenCalled(); + act(() => { + onClick(); + }); + expect(onClickTakePicture).toHaveBeenCalled(); + + 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..c7e1f6403 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useVehicleWalkaround.test.ts @@ -0,0 +1,78 @@ +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: 64 }); + expect(result.current.walkaroundPosition).toEqual(3); + + 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(); + }); +}); From 29bf860e33646539d053d35a7a73e43352574778 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Mon, 16 Dec 2024 19:56:28 +0100 Subject: [PATCH 09/28] Added live config support for VideoCapture apps --- apps/demo-app-video/src/components/App.tsx | 4 +- apps/demo-app-video/src/local-config.json | 197 +---------- apps/demo-app/src/components/App.tsx | 4 +- apps/demo-app/src/local-config.json | 5 +- .../PhotoCapturePage/PhotoCapturePage.tsx | 2 + .../VehicleTypeSelectionPage.tsx | 5 +- documentation/docs/application-state.md | 6 +- documentation/docs/configuration.md | 126 ++++--- documentation/src/utils/schemas.ts | 327 ------------------ .../src/utils/schemas/cameraConfig.schema.ts | 14 + .../src/utils/schemas/compliance.schema.ts | 46 +++ .../utils/schemas/createInspection.schema.ts | 81 +++++ documentation/src/utils/schemas/index.ts | 10 + .../src/utils/schemas/palette.schema.ts | 47 +++ .../schemas/photoCaptureConfig.schema.ts | 37 ++ .../src/utils/schemas/sharedConfig.schema.ts | 63 ++++ .../src/utils/schemas/sights.validator.ts | 34 ++ .../src/utils/schemas/steeringWheel.schema.ts | 29 ++ .../schemas/videoCaptureConfig.schema.ts | 9 + packages/common-ui-web/README.md | 2 +- .../LiveConfigAppProvider.tsx | 8 +- .../components/LiveConfigAppProvider.test.tsx | 4 +- packages/common/README/APP_UTILS.md | 10 +- packages/common/README/UTILITIES.md | 2 +- packages/common/src/apps/appState.ts | 59 +++- packages/common/src/apps/appStateProvider.tsx | 157 +++++++-- packages/common/src/apps/monitoring.ts | 7 +- packages/common/src/utils/config.utils.ts | 4 +- .../test/apps/appStateProvider.test.tsx | 43 ++- .../common/test/utils/config.utils.test.ts | 8 +- packages/inspection-capture-web/README.md | 43 +-- .../src/PhotoCapture/PhotoCapture.tsx | 11 +- .../PhotoCaptureHUD/PhotoCaptureHUD.tsx | 4 +- .../PhotoCaptureHUDElements.tsx | 7 +- .../SightGuideline/SightGuideline.tsx | 7 +- .../PhotoCaptureHUDElementsSight/hooks.ts | 7 +- .../PhotoCaptureHUDTutorial.tsx | 4 +- .../hooks/useAdaptiveCameraConfig.ts | 7 +- .../hooks/useBadConnectionWarning.ts | 4 +- .../hooks/usePhotoCaptureTutorial.ts | 4 +- .../hooks/useStartTasksOnComplete.ts | 4 +- .../src/PhotoCapture/hooks/useUploadQueue.ts | 6 +- .../src/VideoCapture/VideoCapture.tsx | 10 + packages/types/src/config.ts | 185 ++++++---- 44 files changed, 883 insertions(+), 770 deletions(-) delete mode 100644 documentation/src/utils/schemas.ts create mode 100644 documentation/src/utils/schemas/cameraConfig.schema.ts create mode 100644 documentation/src/utils/schemas/compliance.schema.ts create mode 100644 documentation/src/utils/schemas/createInspection.schema.ts create mode 100644 documentation/src/utils/schemas/index.ts create mode 100644 documentation/src/utils/schemas/palette.schema.ts create mode 100644 documentation/src/utils/schemas/photoCaptureConfig.schema.ts create mode 100644 documentation/src/utils/schemas/sharedConfig.schema.ts create mode 100644 documentation/src/utils/schemas/sights.validator.ts create mode 100644 documentation/src/utils/schemas/steeringWheel.schema.ts create mode 100644 documentation/src/utils/schemas/videoCaptureConfig.schema.ts diff --git a/apps/demo-app-video/src/components/App.tsx b/apps/demo-app-video/src/components/App.tsx index 763f3573d..a165469ca 100644 --- a/apps/demo-app-video/src/components/App.tsx +++ b/apps/demo-app-video/src/components/App.tsx @@ -2,13 +2,13 @@ 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 LiveConfig) : undefined; export function App() { const navigate = useNavigate(); diff --git a/apps/demo-app-video/src/local-config.json b/apps/demo-app-video/src/local-config.json index cc5c3e70f..326031abc 100644 --- a/apps/demo-app-video/src/local-config.json +++ b/apps/demo-app-video/src/local-config.json @@ -1,210 +1,23 @@ { - "id": "demo-app-dev", - "description": "Config for the dev Demo App.", - "allowSkipRetake": true, - "enableAddDamage": true, - "enableSightGuidelines": true, - "allowVehicleTypeSelection": true, + "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", "wheel_analysis"] }, - "apiDomain": "api.staging.monk.ai/v1", + "apiDomain": "api.preview.monk.ai/v1", "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize", - "enableSightTutorial": false, "startTasksOnComplete": true, - "showCloseButton": false, - "enforceOrientation": "landscape", + "enforceOrientation": "portrait", "maxUploadDurationWarning": 15000, "useAdaptiveImageQuality": true, "format": "image/jpeg", "quality": 0.6, "resolution": "4K", "allowImageUpscaling": false, - "enableCompliance": true, - "useLiveCompliance": true, - "complianceIssues": [ - "blurriness", - "underexposure", - "overexposure", - "lens_flare", - "reflections", - "unknown_sight", - "unknown_viewpoint", - "no_vehicle", - "wrong_center_part", - "missing_parts", - "hidden_parts", - "missing" - ], - "complianceIssuesPerSight": { - "ff150-nF_oFvhI": [ - "blurriness", - "underexposure", - "overexposure", - "lens_flare", - "reflections", - "missing" - ] - }, - "defaultVehicleType": "cuv", - "enableSteeringWheelPosition": false, - "sights": { - "suv": [ - "jgc21-QIvfeg0X", - "jgc21-KyUUVU2P", - "jgc21-zCrDwYWE", - "jgc21-z15ZdJL6", - "jgc21-RE3li6rE", - "jgc21-omlus7Ui", - "jgc21-m2dDoMup", - "jgc21-3gjMwvQG", - "jgc21-ezXzTRkj", - "jgc21-tbF2Ax8v", - "jgc21-3JJvM7_B", - "jgc21-RAVpqaE4", - "jgc21-F-PPd4qN", - "jgc21-XXh8GWm8", - "jgc21-TRN9Des4", - "jgc21-s7WDTRmE", - "jgc21-__JKllz9" - ], - "cuv": [ - "fesc20-H1dfdfvH", - "fesc20-WMUaKDp1", - "fesc20-LTe3X2bg", - "fesc20-WIQsf_gX", - "fesc20-hp3Tk53x", - "fesc20-fOt832UV", - "fesc20-NLdqASzl", - "fesc20-4Wqx52oU", - "fesc20-dfICsfSV", - "fesc20-X8k7UFGf", - "fesc20-LZc7p2kK", - "fesc20-5Ts1UkPT", - "fesc20-gg1Xyrpu", - "fesc20-P0oSEh8p", - "fesc20-j3H8Z415", - "fesc20-dKVLig1i", - "fesc20-Wzdtgqqz" - ], - "sedan": [ - "haccord-8YjMcu0D", - "haccord-DUPnw5jj", - "haccord-hsCc_Nct", - "haccord-GQcZz48C", - "haccord-QKfhXU7o", - "haccord-mdZ7optI", - "haccord-bSAv3Hrj", - "haccord-W-Bn3bU1", - "haccord-GdWvsqrm", - "haccord-ps7cWy6K", - "haccord-Jq65fyD4", - "haccord-OXYy5gET", - "haccord-5LlCuIfL", - "haccord-Gtt0JNQl", - "haccord-cXSAj2ez", - "haccord-KN23XXkX", - "haccord-Z84erkMb" - ], - "hatchback": [ - "ffocus18-XlfgjQb9", - "ffocus18-3TiCVAaN", - "ffocus18-43ljK5xC", - "ffocus18-x_1SE7X-", - "ffocus18-QKfhXU7o", - "ffocus18-yo9eBDW6", - "ffocus18-cPUyM28L", - "ffocus18-S3kgFOBb", - "ffocus18-9MeSIqp7", - "ffocus18-X2LDjCvr", - "ffocus18-jWOq2CNN", - "ffocus18-P2jFq1Ea", - "ffocus18-U3Bcfc2Q", - "ffocus18-ts3buSD1", - "ffocus18-cXSAj2ez", - "ffocus18-KkeGvT-F", - "ffocus18-lRDlWiwR" - ], - "van": [ - "ftransit18-wyXf7MTv", - "ftransit18-UNAZWJ-r", - "ftransit18-5SiNC94w", - "ftransit18-Y0vPhBVF", - "ftransit18-xyp1rU0h", - "ftransit18-6khKhof0", - "ftransit18-eXJDDYmE", - "ftransit18-3Sbfx_KZ", - "ftransit18-iu1Vj2Oa", - "ftransit18-aA2K898S", - "ftransit18-NwBMLo3Z", - "ftransit18-cf0e-pcB", - "ftransit18-FFP5b34o", - "ftransit18-RJ2D7DNz", - "ftransit18-3fnjrISV", - "ftransit18-eztNpSRX", - "ftransit18-TkXihCj4", - "ftransit18-4NMPqEV6", - "ftransit18-IIVI_pnX" - ], - "minivan": [ - "tsienna20-YwrRNr9n", - "tsienna20-HykkFbXf", - "tsienna20-TI4TVvT9", - "tsienna20-65mfPdRD", - "tsienna20-Ia0SGJ6z", - "tsienna20-1LNxhgCR", - "tsienna20-U_FqYq-a", - "tsienna20-670P2H2V", - "tsienna20-1n_z8bYy", - "tsienna20-qA3aAUUq", - "tsienna20--a2RmRcs", - "tsienna20-SebsoqJm", - "tsienna20-u57qDaN_", - "tsienna20-Rw0Gtt7O", - "tsienna20-TibS83Qr", - "tsienna20-cI285Gon", - "tsienna20-KHB_Cd9k" - ], - "pickup": [ - "ff150-zXbg0l3z", - "ff150-3he9UOwy", - "ff150-KgHVkQBW", - "ff150-FqbrFVr2", - "ff150-g_xBOOS2", - "ff150-vwE3yqdh", - "ff150-V-xzfWsx", - "ff150-ouGGtRnf", - "ff150--xPZZd83", - "ff150-nF_oFvhI", - "ff150-t3KBMPeD", - "ff150-3rM9XB0Z", - "ff150-eOjyMInj", - "ff150-18YVVN-G", - "ff150-BmXfb-qD", - "ff150-gFp78fQO", - "ff150-7nvlys8r" - ] - }, - "sightGuidelines": [ - { - "en": "Kneel or bend low in front of the car, aligned with the grille. Centre the car in the frame, ensuring the front grille, headlights, and registration plate are fully visible. Include the top of the car and the bottom of the bumper in the shot.", - "fr": "Agenouillez-vous ou penchez-vous devant la voiture, aligné avec la calandre. Centrez la voiture dans le cadre, en vous assurant que la calandre avant, les phares et la plaque d'immatriculation soient bien visibles. Veillez à inclure le toit de la voiture et le bas du pare-chocs dans la photo.", - "nl": "Kniel of buk laag voor de auto, uitgelijnd met het rooster. Centreer de auto in het frame, zorg ervoor dat het voorrooster, de koplampen en het kenteken volledig zichtbaar zijn. Zorg ervoor dat de bovenkant van de auto en de onderkant van de bumper in de foto zichtbaar zijn.", - "de": "Knien oder beugen Sie sich vor dem Auto, ausgerichtet mit dem Kühlergrill. Zentrieren Sie das Auto im Bild, sodass der vordere Kühlergrill, die Scheinwerfer und das Nummernschild vollständig sichtbar sind. Achten Sie darauf, dass das Dach des Autos und der untere Teil der Stoßstange im Bild zu sehen sind.", - "sightIds": [ - "ffocus18-XlfgjQb9", - "jgc21-QIvfeg0X", - "ff150-zXbg0l3z", - "ftransit18-wyXf7MTv", - "tsienna20-YwrRNr9n", - "fesc20-H1dfdfvH", - "haccord-8YjMcu0D" - ] - } - ], "requiredApiPermissions": [ "monk_core_api:compliances", "monk_core_api:damage_detection", diff --git a/apps/demo-app/src/components/App.tsx b/apps/demo-app/src/components/App.tsx index 763f3573d..a165469ca 100644 --- a/apps/demo-app/src/components/App.tsx +++ b/apps/demo-app/src/components/App.tsx @@ -2,13 +2,13 @@ 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 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/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..5b55e2c50 100644 --- a/documentation/docs/configuration.md +++ b/documentation/docs/configuration.md @@ -4,57 +4,105 @@ 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. | ✔️ | | +### 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` | +| 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` | +| 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` | +| 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). | ✔️ | | -| 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. | | | +| defaultSteeringWheelPosition | `SteeringWheelPosition` | Default steering wheel position to use if no steering wheel position has been specified. | if `enableSteeringWheelPosition` is set to `true` | | -## 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. +## 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 | +|------|------|-------------|----------|---------------| +| _ | `_` | _ | ✔️ | | 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..48e464e77 --- /dev/null +++ b/documentation/src/utils/schemas/createInspection.schema.ts @@ -0,0 +1,81 @@ +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(), + 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..8295ce7b4 --- /dev/null +++ b/documentation/src/utils/schemas/photoCaptureConfig.schema.ts @@ -0,0 +1,37 @@ +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(), + 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..29020245e --- /dev/null +++ b/documentation/src/utils/schemas/sharedConfig.schema.ts @@ -0,0 +1,63 @@ +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(), + maxUploadDurationWarning: z.number().optional(), + useAdaptiveImageQuality: z.boolean().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..d88e52342 --- /dev/null +++ b/documentation/src/utils/schemas/videoCaptureConfig.schema.ts @@ -0,0 +1,9 @@ +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), + }) + .and(SharedCaptureAppConfigSchema); diff --git a/packages/common-ui-web/README.md b/packages/common-ui-web/README.md index f28a42340..49ba73d3b 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` | --- 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/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/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/UTILITIES.md b/packages/common/README/UTILITIES.md index ac7b3a8f2..bb0488c2a 100644 --- a/packages/common/README/UTILITIES.md +++ b/packages/common/README/UTILITIES.md @@ -135,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/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/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..d9cf521ed 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. @@ -64,23 +64,15 @@ export function MonkPhotoCapturePage({ authToken }) { |------------------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------| | sights | Sight[] | The list of Sights to take pictures of. The values in this array should be retreived from the `@monkvision/sights` package. | ✔️ | | | 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. | | | | 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 +80,20 @@ 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. | | | +| 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. | | | | 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. | ✔️ | | +| 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. | ✔️ | | +| 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. | | | +| 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` | +| lang | string | null | The language to be used by this component. | | `'en'` | diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index c0af662b7..48f929e0b 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -1,5 +1,5 @@ import { useAnalytics } from '@monkvision/analytics'; -import { Camera, CameraHUDProps, CameraProps } from '@monkvision/camera-web'; +import { Camera, CameraHUDProps } from '@monkvision/camera-web'; import { useI18nSync, useLoadingState, @@ -18,11 +18,10 @@ import { useMonitoring } from '@monkvision/monitoring'; import { MonkApiConfig } from '@monkvision/network'; import { CameraConfig, - CaptureAppConfig, ComplianceOptions, - CompressionOptions, DeviceOrientation, MonkPicture, + PhotoCaptureAppConfig, PhotoCaptureTutorialOption, Sight, } from '@monkvision/types'; @@ -48,9 +47,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 +65,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` diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx index 67394cce7..d9282d82c 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { CaptureAppConfig, Image, ImageStatus, Sight } from '@monkvision/types'; +import { PhotoCaptureAppConfig, Image, ImageStatus, Sight } from '@monkvision/types'; import { useTranslation } from 'react-i18next'; import { BackdropDialog } from '@monkvision/common-ui-web'; import { CameraHUDProps } from '@monkvision/camera-web'; @@ -18,7 +18,7 @@ import { PhotoCaptureHUDTutorial } from './PhotoCaptureHUDTutorial'; export interface PhotoCaptureHUDProps extends CameraHUDProps, Pick< - CaptureAppConfig, + PhotoCaptureAppConfig, | 'enableSightGuidelines' | 'sightGuidelines' | 'enableAddDamage' 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/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/useStartTasksOnComplete.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts index fe1a2039c..e9c3ba829 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/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,7 +8,7 @@ import { useCallback } from 'react'; * Parameters of the useStartTasksOnComplete hook. */ export interface UseStartTasksOnCompleteParams - extends Pick { + extends Pick { /** * The inspection ID. */ 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.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx index bc0fbc56c..8afe1c5d8 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx @@ -1,6 +1,7 @@ import { useI18nSync, useDeviceOrientation } from '@monkvision/common'; import { useState } from 'react'; import { Camera } from '@monkvision/camera-web'; +import { MonkApiConfig } from '@monkvision/network'; import { styles } from './VideoCapture.styles'; import { VideoCapturePermissions } from './VideoCapturePermissions'; import { VideoCaptureHUD } from './VideoCaptureHUD'; @@ -9,6 +10,15 @@ import { VideoCaptureHUD } from './VideoCaptureHUD'; * Props of the VideoCapture component. */ export interface VideoCaptureProps { + /** + * 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 language to be used by this component. * diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 6e06506fb..1313f49cd 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,52 +66,106 @@ export type CameraConfig = Partial & { }; /** - * The configuration options for inspection capture applications. + * Shared config used by both PhotoCapture and VideoCapture apps. + */ +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; + /** + * 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. + * + * @default 15000 + */ + maxUploadDurationWarning?: number; + /** + * Boolean indicating if the image quality should be downgraded automatically in case of low connection. + * + * @default true + */ + useAdaptiveImageQuality?: boolean; + /** + * 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 CaptureAppConfig = CameraConfig & +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. - * - * @default 15000 - */ - maxUploadDurationWarning?: number; - /** - * Boolean indicating if the image quality should be downgraded automatically in case of low connection. - * - * @default true - */ - useAdaptiveImageQuality?: boolean; /** * If compliance is enabled, this prop indicate if the user is allowed to skip the retaking process if pictures are * not compliant. @@ -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,22 @@ 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; +}; + /** * 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. */ From e6bd8a5274a7e1d11ffaf6cf644e3f731db49005 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Tue, 24 Dec 2024 11:23:30 +0100 Subject: [PATCH 10/28] Created useVideoRecording hook --- apps/demo-app-video/package.json | 36 ++-- apps/demo-app-video/src/components/App.tsx | 4 +- apps/demo-app-video/src/local-config.json | 1 + .../VideoCapturePage/VideoCapturePage.tsx | 16 +- apps/demo-app/src/components/App.tsx | 4 +- .../schemas/videoCaptureConfig.schema.ts | 1 + .../BackdropDialog/BackdropDialog.styles.ts | 3 + .../BackdropDialog/BackdropDialog.tsx | 1 + .../src/components/BackdropDialog/hooks.ts | 4 +- .../RecordVideoButton.styles.ts | 2 + .../src/VideoCapture/VideoCapture.tsx | 35 ++- .../VideoCaptureHUD/VideoCaptureHUD.tsx | 71 +++++-- .../VideoCaptureRecording.tsx | 27 ++- .../VideoCaptureRecording.types.ts | 8 + .../VideoCaptureRecordingStyles.ts | 31 ++- .../src/VideoCapture/hooks/index.ts | 1 + .../hooks/useVehicleWalkaround.ts | 18 +- .../VideoCapture/hooks/useVideoRecording.ts | 157 ++++++++++++++ .../src/translations/de.json | 7 + .../src/translations/en.json | 7 + .../src/translations/fr.json | 7 + .../src/translations/nl.json | 7 + .../VideoCaptureRecording.test.tsx | 107 ++++++---- .../hooks/useVideoRecording.test.ts | 201 ++++++++++++++++++ packages/types/src/config.ts | 6 + yarn.lock | 34 +-- 26 files changed, 695 insertions(+), 101 deletions(-) create mode 100644 packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts create mode 100644 packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts diff --git a/apps/demo-app-video/package.json b/apps/demo-app-video/package.json index c06d186f9..86b5f415f 100644 --- a/apps/demo-app-video/package.json +++ b/apps/demo-app-video/package.json @@ -1,6 +1,6 @@ { "name": "monk-demo-app-video", - "version": "4.5.3", + "version": "4.5.5", "license": "BSD-3-Clause-Clear", "packageManager": "yarn@3.2.4", "description": "MonkJs demo app for Video capture with React and TypeScript", @@ -25,16 +25,16 @@ }, "dependencies": { "@auth0/auth0-react": "^2.2.4", - "@monkvision/analytics": "4.5.3", - "@monkvision/common": "4.5.3", - "@monkvision/common-ui-web": "4.5.3", - "@monkvision/inspection-capture-web": "4.5.3", - "@monkvision/monitoring": "4.5.3", - "@monkvision/network": "4.5.3", - "@monkvision/posthog": "4.5.3", - "@monkvision/sentry": "4.5.3", - "@monkvision/sights": "4.5.3", - "@monkvision/types": "4.5.3", + "@monkvision/analytics": "4.5.5", + "@monkvision/common": "4.5.5", + "@monkvision/common-ui-web": "4.5.5", + "@monkvision/inspection-capture-web": "4.5.5", + "@monkvision/monitoring": "4.5.5", + "@monkvision/network": "4.5.5", + "@monkvision/posthog": "4.5.5", + "@monkvision/sentry": "4.5.5", + "@monkvision/sights": "4.5.5", + "@monkvision/types": "4.5.5", "@types/babel__core": "^7", "@types/jest": "^27.5.2", "@types/node": "^16.18.18", @@ -60,13 +60,13 @@ }, "devDependencies": { "@babel/core": "^7.22.9", - "@monkvision/eslint-config-base": "4.5.3", - "@monkvision/eslint-config-typescript": "4.5.3", - "@monkvision/eslint-config-typescript-react": "4.5.3", - "@monkvision/jest-config": "4.5.3", - "@monkvision/prettier-config": "4.5.3", - "@monkvision/test-utils": "4.5.3", - "@monkvision/typescript-config": "4.5.3", + "@monkvision/eslint-config-base": "4.5.5", + "@monkvision/eslint-config-typescript": "4.5.5", + "@monkvision/eslint-config-typescript-react": "4.5.5", + "@monkvision/jest-config": "4.5.5", + "@monkvision/prettier-config": "4.5.5", + "@monkvision/test-utils": "4.5.5", + "@monkvision/typescript-config": "4.5.5", "@testing-library/dom": "^8.20.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", diff --git a/apps/demo-app-video/src/components/App.tsx b/apps/demo-app-video/src/components/App.tsx index a165469ca..b86bf83c1 100644 --- a/apps/demo-app-video/src/components/App.tsx +++ b/apps/demo-app-video/src/components/App.tsx @@ -8,7 +8,9 @@ import * as config from '../local-config.json'; import { AppContainer } from './AppContainer'; const localConfig = - process.env['REACT_APP_USE_LOCAL_CONFIG'] === 'true' ? (config as LiveConfig) : 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-video/src/local-config.json b/apps/demo-app-video/src/local-config.json index 326031abc..cf8344dd2 100644 --- a/apps/demo-app-video/src/local-config.json +++ b/apps/demo-app-video/src/local-config.json @@ -12,6 +12,7 @@ "thumbnailDomain": "europe-west1-monk-preview-321715.cloudfunctions.net/image_resize", "startTasksOnComplete": true, "enforceOrientation": "portrait", + "minRecordingDuration": 15000, "maxUploadDurationWarning": 15000, "useAdaptiveImageQuality": true, "format": "image/jpeg", diff --git a/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx index 5ed83f1a7..aa8be6808 100644 --- a/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx +++ b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx @@ -1,13 +1,27 @@ 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'; export function VideoCapturePage() { const { i18n } = useTranslation(); + const { config } = useMonkAppState({ + requireWorkflow: CaptureWorkflow.VIDEO, + }); return (
- +
); } diff --git a/apps/demo-app/src/components/App.tsx b/apps/demo-app/src/components/App.tsx index a165469ca..b86bf83c1 100644 --- a/apps/demo-app/src/components/App.tsx +++ b/apps/demo-app/src/components/App.tsx @@ -8,7 +8,9 @@ import * as config from '../local-config.json'; import { AppContainer } from './AppContainer'; const localConfig = - process.env['REACT_APP_USE_LOCAL_CONFIG'] === 'true' ? (config as LiveConfig) : undefined; + process.env['REACT_APP_USE_LOCAL_CONFIG'] === 'true' + ? (config as unknown as LiveConfig) + : undefined; export function App() { const navigate = useNavigate(); diff --git a/documentation/src/utils/schemas/videoCaptureConfig.schema.ts b/documentation/src/utils/schemas/videoCaptureConfig.schema.ts index d88e52342..4c1761e87 100644 --- a/documentation/src/utils/schemas/videoCaptureConfig.schema.ts +++ b/documentation/src/utils/schemas/videoCaptureConfig.schema.ts @@ -5,5 +5,6 @@ import { SharedCaptureAppConfigSchema } from '@site/src/utils/schemas/sharedConf export const VideoCaptureAppConfigSchema = z .object({ workflow: z.literal(CaptureWorkflow.VIDEO), + minRecordingDuration: z.number().optional(), }) .and(SharedCaptureAppConfigSchema); 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..0e41b3e0f 100644 --- a/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.styles.ts +++ b/packages/common-ui-web/src/components/BackdropDialog/BackdropDialog.styles.ts @@ -32,6 +32,9 @@ export const styles: Styles = { justifyContent: 'center', fontSize: 18, }, + messageNoIcon: { + paddingTop: 30, + }, buttonsContainer: { width: '100%', display: 'flex', 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/RecordVideoButton/RecordVideoButton.styles.ts b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts index 464c2b010..f7586ec91 100644 --- a/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts +++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.styles.ts @@ -29,6 +29,8 @@ export const styles: Styles = { display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: 0, + margin: 0, }, buttonDisabled: { opacity: 0.75, diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx index 8afe1c5d8..821cb26ad 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx @@ -1,15 +1,26 @@ import { useI18nSync, useDeviceOrientation } from '@monkvision/common'; import { useState } from 'react'; -import { Camera } from '@monkvision/camera-web'; +import { Camera, CameraHUDProps } from '@monkvision/camera-web'; import { MonkApiConfig } from '@monkvision/network'; +import { CameraConfig, VideoCaptureAppConfig } from '@monkvision/types'; import { styles } from './VideoCapture.styles'; import { VideoCapturePermissions } from './VideoCapturePermissions'; -import { VideoCaptureHUD } from './VideoCaptureHUD'; +import { VideoCaptureHUD, VideoCaptureHUDProps } from './VideoCaptureHUD'; /** * Props of the VideoCapture component. */ -export interface VideoCaptureProps { +export interface VideoCaptureProps + extends Pick< + VideoCaptureAppConfig, + | keyof CameraConfig + | 'maxUploadDurationWarning' + | 'useAdaptiveImageQuality' + | 'additionalTasks' + | 'startTasksOnComplete' + | 'enforceOrientation' + | 'minRecordingDuration' + > { /** * The ID of the inspection to add the video frames to. */ @@ -33,12 +44,26 @@ enum VideoCaptureScreen { } // No ts-doc for this component : the component exported is VideoCaptureHOC -export function VideoCapture({ lang }: VideoCaptureProps) { +export function VideoCapture({ + inspectionId, + apiConfig, + maxUploadDurationWarning, + useAdaptiveImageQuality, + additionalTasks, + startTasksOnComplete, + enforceOrientation, + minRecordingDuration = 15000, + lang, +}: VideoCaptureProps) { useI18nSync(lang); const [screen, setScreen] = useState(VideoCaptureScreen.PERMISSIONS); const { requestCompassPermission, alpha } = useDeviceOrientation(); - const hudProps = { alpha }; + const hudProps: Omit = { + alpha, + minRecordingDuration, + onRecordingComplete: () => console.log('Recording complete!'), + }; return (
diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx index 30a5c7fca..a10482587 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx @@ -1,38 +1,73 @@ import { useState } from 'react'; import { CameraHUDProps } from '@monkvision/camera-web'; +import { useMonitoring } from '@monkvision/monitoring'; +import { BackdropDialog } from '@monkvision/common-ui-web'; +import { useTranslation } from 'react-i18next'; import { styles } from './VideoCaptureHUD.styles'; import { VideoCaptureTutorial } from './VideoCaptureTutorial'; import { VideoCaptureRecording } from './VideoCaptureRecording'; -import { useVehicleWalkaround } from '../hooks'; +import { useVehicleWalkaround, useVideoRecording, UseVideoRecordingParams } from '../hooks'; /** * Props accepted by the VideoCaptureHUD component. */ -export interface VideoCaptureHUDProps extends CameraHUDProps { +export interface VideoCaptureHUDProps + extends CameraHUDProps, + Pick { /** * The alpha value of the device orientaiton. */ alpha: number; + /** + * Callback called when the recording is complete. + */ + onRecordingComplete?: () => void; } +const SCREENSHOT_INTERVAL_MS = 1000; + /** * HUD component displayed on top of the camera preview for the VideoCapture process. */ -export function VideoCaptureHUD({ handle, cameraPreview, alpha }: VideoCaptureHUDProps) { +export function VideoCaptureHUD({ + handle, + cameraPreview, + alpha, + minRecordingDuration, + onRecordingComplete, +}: VideoCaptureHUDProps) { const [isTutorialDisplayed, setIsTutorialDisplayed] = useState(true); - const [isRecording, setIsRecording] = useState(false); + const { t } = useTranslation(); + const { handleError } = useMonitoring(); const { walkaroundPosition, startWalkaround } = useVehicleWalkaround({ alpha }); - const onClickRecordVideo = () => { - if (isRecording) { - setIsRecording(false); - } else { - startWalkaround(); - setIsRecording(true); + const onCaptureVideoFrame = async () => { + try { + const picture = await handle.takePicture(); + console.log('Picture taken :', picture.blob.size); + } catch (err) { + handleError(err); } }; - const onClickTakePicture = () => {}; + const { + isRecording, + isRecordingPaused, + onClickRecordVideo, + onDiscardDialogKeepRecording, + onDiscardDialogDiscardVideo, + isDiscardDialogDisplayed, + recordingDurationMs, + } = useVideoRecording({ + screenshotInterval: SCREENSHOT_INTERVAL_MS, + minRecordingDuration, + walkaroundPosition, + startWalkaround, + onCaptureVideoFrame, + onRecordingComplete, + }); + + const handleTakePictureClick = () => {}; return (
@@ -42,13 +77,23 @@ export function VideoCaptureHUD({ handle, cameraPreview, alpha }: VideoCaptureHU setIsTutorialDisplayed(false)} /> ) : ( )}
+
); } diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx index f7742c1fe..5d7bc7731 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx @@ -6,17 +6,35 @@ import { 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, }: VideoCaptureRecordingProps) { - const { container, controls, takePictureFlash, walkaroundIndicator, showTakePictureFlash } = - useVideoCaptureRecordingStyles({ isRecording }); + const { + container, + indicators, + recordingDuration, + controls, + takePictureFlash, + walkaroundIndicator, + showTakePictureFlash, + } = useVideoCaptureRecordingStyles({ isRecording }); const handleTakePictureClick = () => { showTakePictureFlash(); @@ -25,6 +43,11 @@ export function VideoCaptureRecording({ 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 index d4a83b7dc..72afb8f4b 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts @@ -10,6 +10,14 @@ export interface VideoCaptureRecordingProps { * 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. */ diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts index 685d556b0..6ba93966c 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts @@ -1,5 +1,5 @@ import { Styles } from '@monkvision/types'; -import { useIsMounted, useResponsiveStyle } from '@monkvision/common'; +import { useIsMounted, useMonkTheme, useResponsiveStyle } from '@monkvision/common'; import { useState } from 'react'; import { VideoCaptureRecordingProps } from './VideoCaptureRecording.types'; @@ -10,13 +10,30 @@ export const styles: Styles = { display: 'flex', flexDirection: 'column', alignItems: 'center', - justifyContent: 'flex-end', + 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', @@ -53,6 +70,7 @@ export function useVideoCaptureRecordingStyles({ isRecording, }: Pick) { const [isTakePictureFlashVisible, setTakePictureFlashVisible] = useState(false); + const { palette } = useMonkTheme(); const { responsive } = useResponsiveStyle(); const isMounted = useIsMounted(); @@ -70,6 +88,15 @@ export function useVideoCaptureRecordingStyles({ ...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']), diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts index 19e2e9457..eca09ca65 100644 --- a/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts @@ -1 +1,2 @@ export * from './useVehicleWalkaround'; +export * from './useVideoRecording'; diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts index 85d98ac3a..47ef5f0c6 100644 --- a/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVehicleWalkaround.ts @@ -11,10 +11,26 @@ export interface UseVehicleWalkaroundParams { 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) { +export function useVehicleWalkaround({ + alpha, +}: UseVehicleWalkaroundParams): VehicleWalkaroundHandle { const [startingAlpha, setStartingAlpha] = useState(null); const [checkpoint, setCheckpoint] = useState(45); const [nextCheckpoint, setNextCheckpoint] = useState(90); 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..d451f4824 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import { useInterval } from '@monkvision/common'; +import { VehicleWalkaroundHandle } from './useVehicleWalkaround'; + +/** + * Params accepted by the useVideoRecording hook. + */ +export interface UseVideoRecordingParams + extends Pick { + /** + * 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 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; + /** + * 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; +} + +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({ + screenshotInterval, + minRecordingDuration, + walkaroundPosition, + startWalkaround, + onCaptureVideoFrame, + onRecordingComplete, +}: UseVideoRecordingParams): VideoRecordingHandle { + const [isRecording, setIsRecording] = useState(false); + const [isRecordingPaused, setIsRecordingPaused] = useState(false); + const [additionalRecordingDuration, setAdditionalRecordingDuration] = useState(0); + const [recordingStartTimestamp, setRecordingStartTimestamp] = useState(null); + const [isDiscardDialogDisplayed, setDiscardDialogDisplayed] = useState(false); + + const recordingDurationMs = + additionalRecordingDuration + + (recordingStartTimestamp ? Date.now() - recordingStartTimestamp : 0); + + const pauseRecording = () => { + setAdditionalRecordingDuration((value) => + recordingStartTimestamp ? value + Date.now() - recordingStartTimestamp : value, + ); + setRecordingStartTimestamp(null); + setIsRecording(false); + setIsRecordingPaused(true); + }; + + const resumeRecording = () => { + setRecordingStartTimestamp(Date.now()); + setIsRecording(true); + setIsRecordingPaused(false); + }; + + const onClickRecordVideo = () => { + if (isRecording) { + if ( + recordingDurationMs < minRecordingDuration || + walkaroundPosition < MINIMUM_VEHICLE_WALKAROUND_POSITION + ) { + pauseRecording(); + setDiscardDialogDisplayed(true); + } else { + setIsRecording(false); + onRecordingComplete?.(); + } + } else { + setAdditionalRecordingDuration(0); + setRecordingStartTimestamp(Date.now()); + setIsRecording(true); + startWalkaround(); + } + }; + + const onDiscardDialogKeepRecording = () => { + resumeRecording(); + setDiscardDialogDisplayed(false); + }; + + const onDiscardDialogDiscardVideo = () => { + setIsRecordingPaused(false); + setAdditionalRecordingDuration(0); + setRecordingStartTimestamp(null); + setIsRecording(false); + setDiscardDialogDisplayed(false); + }; + + useInterval( + () => { + if (isRecording) { + onCaptureVideoFrame?.(); + } + }, + isRecording ? screenshotInterval : null, + ); + + return { + isRecording, + isRecordingPaused, + recordingDurationMs, + onClickRecordVideo, + onDiscardDialogKeepRecording, + onDiscardDialogDiscardVideo, + isDiscardDialogDisplayed, + }; +} diff --git a/packages/inspection-capture-web/src/translations/de.json b/packages/inspection-capture-web/src/translations/de.json index 637edb5e9..0eeef5166 100644 --- a/packages/inspection-capture-web/src/translations/de.json +++ b/packages/inspection-capture-web/src/translations/de.json @@ -74,6 +74,13 @@ "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" + } } } } diff --git a/packages/inspection-capture-web/src/translations/en.json b/packages/inspection-capture-web/src/translations/en.json index 75b8d2b0b..9f365ed2f 100644 --- a/packages/inspection-capture-web/src/translations/en.json +++ b/packages/inspection-capture-web/src/translations/en.json @@ -74,6 +74,13 @@ "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" + } } } } diff --git a/packages/inspection-capture-web/src/translations/fr.json b/packages/inspection-capture-web/src/translations/fr.json index 1a58a2668..7cd572e7d 100644 --- a/packages/inspection-capture-web/src/translations/fr.json +++ b/packages/inspection-capture-web/src/translations/fr.json @@ -74,6 +74,13 @@ "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" + } } } } diff --git a/packages/inspection-capture-web/src/translations/nl.json b/packages/inspection-capture-web/src/translations/nl.json index f10a218f8..c1403cf17 100644 --- a/packages/inspection-capture-web/src/translations/nl.json +++ b/packages/inspection-capture-web/src/translations/nl.json @@ -74,6 +74,13 @@ "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" + } } } } diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx index 3ee6c069d..049bd5846 100644 --- a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx @@ -6,40 +6,58 @@ import { TakePictureButton, VehicleWalkaroundIndicator, } from '@monkvision/common-ui-web'; -import { VideoCaptureRecording } from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording'; +import { + VideoCaptureRecording, + VideoCaptureRecordingProps, +} from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording'; 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(), + }; +} + describe('VideoCaptureRecording component', () => { afterEach(() => { jest.clearAllMocks(); }); it('should display the VehicleWalkaroundIndicator component', () => { - const walkaroundPosition = 344.7; - const { unmount } = render( - , - ); + const props = createProps(); + const { unmount } = render(); - expectPropsOnChildMock(VehicleWalkaroundIndicator, { alpha: walkaroundPosition }); + 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.queryByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).toHaveStyle( + expect(screen.getByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).not.toHaveStyle( disabledStyle, ); - rerender(); - expect(screen.queryByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).not.toHaveStyle( + rerender(); + expect(screen.getByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).toHaveStyle( + disabledStyle, + ); + + rerender(); + expect(screen.getByTestId(VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID)).toHaveStyle( disabledStyle, ); @@ -47,40 +65,34 @@ describe('VideoCaptureRecording component', () => { }); it('should display the RecordVideoButton and pass it the recording state', () => { - const { rerender, unmount } = render( - , - ); + const props = createProps(); + const { rerender, unmount } = render(); expectPropsOnChildMock(RecordVideoButton, { isRecording: true }); - rerender(); + rerender(); expectPropsOnChildMock(RecordVideoButton, { isRecording: false }); unmount(); }); it('should call the onClickRecordVideo callback when the user clicks on the RecordVideoButton', () => { - const onClickRecordVideo = jest.fn(); - const { unmount } = render( - , - ); + 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(onClickRecordVideo).not.toHaveBeenCalled(); + expect(props.onClickRecordVideo).not.toHaveBeenCalled(); act(() => { onClick(); }); - expect(onClickRecordVideo).toHaveBeenCalled(); + expect(props.onClickRecordVideo).toHaveBeenCalled(); unmount(); }); it('should display the TakePictureButton', () => { - const { unmount } = render(); + const props = createProps(); + const { unmount } = render(); expect(TakePictureButton).toHaveBeenCalled(); @@ -88,34 +100,51 @@ describe('VideoCaptureRecording component', () => { }); it('should disable the TakePictureButton when not recording', () => { - const { rerender, unmount } = render( - , - ); + const props = createProps(); + const { rerender, unmount } = render(); expectPropsOnChildMock(TakePictureButton, { disabled: false }); - rerender(); + rerender(); expectPropsOnChildMock(TakePictureButton, { disabled: true }); unmount(); }); it('should call the onClickTakePicture callback when the user clicks on the TakePictureButton', () => { - const onClickTakePicture = jest.fn(); - const { unmount } = render( - , - ); + 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(onClickTakePicture).not.toHaveBeenCalled(); + expect(props.onClickTakePicture).not.toHaveBeenCalled(); act(() => { onClick(); }); - expect(onClickTakePicture).toHaveBeenCalled(); + 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/hooks/useVideoRecording.test.ts b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts new file mode 100644 index 000000000..3e1376f66 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts @@ -0,0 +1,201 @@ +import { useVideoRecording, UseVideoRecordingParams } 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 { + return { + walkaroundPosition: 350, + 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({ + isRecording: false, + isRecordingPaused: false, + recordingDurationMs: 0, + onClickRecordVideo: expect.any(Function), + onDiscardDialogDiscardVideo: expect.any(Function), + onDiscardDialogKeepRecording: expect.any(Function), + isDiscardDialogDisplayed: false, + }), + ); + + 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(result.current.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(result.current.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(result.current.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(result.current.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(result.current.isRecording).toBe(false); + expect(result.current.isRecordingPaused).toBe(false); + jest.advanceTimersByTime(4500); + rerender(); + expect(result.current.recordingDurationMs).toEqual(0); + + unmount(); + }); +}); diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index 1313f49cd..c7bee0ed7 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -253,6 +253,12 @@ export type VideoCaptureAppConfig = SharedCaptureAppConfig & { * The capture workflow of the capture app. */ workflow: CaptureWorkflow.VIDEO; + /** + * The duration of a recording in milliseconds. + * + * @default 15000 + */ + minRecordingDuration?: number; }; /** diff --git a/yarn.lock b/yarn.lock index e1122724d..c9d6a904a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15375,23 +15375,23 @@ __metadata: dependencies: "@auth0/auth0-react": ^2.2.4 "@babel/core": ^7.22.9 - "@monkvision/analytics": 4.5.3 - "@monkvision/common": 4.5.3 - "@monkvision/common-ui-web": 4.5.3 - "@monkvision/eslint-config-base": 4.5.3 - "@monkvision/eslint-config-typescript": 4.5.3 - "@monkvision/eslint-config-typescript-react": 4.5.3 - "@monkvision/inspection-capture-web": 4.5.3 - "@monkvision/jest-config": 4.5.3 - "@monkvision/monitoring": 4.5.3 - "@monkvision/network": 4.5.3 - "@monkvision/posthog": 4.5.3 - "@monkvision/prettier-config": 4.5.3 - "@monkvision/sentry": 4.5.3 - "@monkvision/sights": 4.5.3 - "@monkvision/test-utils": 4.5.3 - "@monkvision/types": 4.5.3 - "@monkvision/typescript-config": 4.5.3 + "@monkvision/analytics": 4.5.5 + "@monkvision/common": 4.5.5 + "@monkvision/common-ui-web": 4.5.5 + "@monkvision/eslint-config-base": 4.5.5 + "@monkvision/eslint-config-typescript": 4.5.5 + "@monkvision/eslint-config-typescript-react": 4.5.5 + "@monkvision/inspection-capture-web": 4.5.5 + "@monkvision/jest-config": 4.5.5 + "@monkvision/monitoring": 4.5.5 + "@monkvision/network": 4.5.5 + "@monkvision/posthog": 4.5.5 + "@monkvision/prettier-config": 4.5.5 + "@monkvision/sentry": 4.5.5 + "@monkvision/sights": 4.5.5 + "@monkvision/test-utils": 4.5.5 + "@monkvision/types": 4.5.5 + "@monkvision/typescript-config": 4.5.5 "@testing-library/dom": ^8.20.0 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^12.1.5 From a4ce4d7563796828881e155120d58e54e44cf79f Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Thu, 26 Dec 2024 10:25:49 +0100 Subject: [PATCH 11/28] Added getImageData and compressImage functions to the Camera handle. --- packages/camera-web/README.md | 18 ++++++----- packages/camera-web/src/Camera/Camera.tsx | 8 ++++- .../camera-web/src/Camera/CameraHUD.types.ts | 19 ++++++++++++ .../src/Camera/hooks/useCameraScreenshot.ts | 28 ++++++++--------- .../src/Camera/hooks/useCompression.ts | 30 +++++++++---------- .../camera-web/test/Camera/Camera.test.tsx | 15 ++++++++++ .../Camera/hooks/useCameraScreenshot.test.ts | 12 ++++++++ .../test/Camera/hooks/useCompression.test.ts | 13 ++++++++ 8 files changed, 105 insertions(+), 38 deletions(-) diff --git a/packages/camera-web/README.md b/packages/camera-web/README.md index 7a2b29b24..d39404cc0 100644 --- a/packages/camera-web/README.md +++ b/packages/camera-web/README.md @@ -172,14 +172,16 @@ 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 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/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(); + }); }); }); From 58c94f77d7d89da77b6d669ee49a33365b74bba9 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Thu, 26 Dec 2024 11:56:02 +0100 Subject: [PATCH 12/28] Created useFrameSelection hook --- .../VideoCaptureHUD/VideoCaptureHUD.tsx | 27 +-- .../src/VideoCapture/hooks/index.ts | 1 + .../hooks/useFrameSelection/index.ts | 1 + .../hooks/useFrameSelection/laplaceScores.ts | 68 ++++++++ .../useFrameSelection/useFrameSelection.ts | 82 +++++++++ .../hooks/useFrameSelection.test.ts | 155 ++++++++++++++++++ 6 files changed, 323 insertions(+), 11 deletions(-) create mode 100644 packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/index.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/laplaceScores.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/useFrameSelection.ts create mode 100644 packages/inspection-capture-web/test/VideoCapture/hooks/useFrameSelection.test.ts diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx index a10482587..9ef335c8c 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx @@ -1,12 +1,17 @@ import { useState } from 'react'; import { CameraHUDProps } from '@monkvision/camera-web'; -import { useMonitoring } from '@monkvision/monitoring'; import { BackdropDialog } from '@monkvision/common-ui-web'; import { useTranslation } from 'react-i18next'; +import { MonkPicture } from '@monkvision/types'; import { styles } from './VideoCaptureHUD.styles'; import { VideoCaptureTutorial } from './VideoCaptureTutorial'; import { VideoCaptureRecording } from './VideoCaptureRecording'; -import { useVehicleWalkaround, useVideoRecording, UseVideoRecordingParams } from '../hooks'; +import { + useFrameSelection, + useVehicleWalkaround, + useVideoRecording, + UseVideoRecordingParams, +} from '../hooks'; /** * Props accepted by the VideoCaptureHUD component. @@ -24,7 +29,8 @@ export interface VideoCaptureHUDProps onRecordingComplete?: () => void; } -const SCREENSHOT_INTERVAL_MS = 1000; +const SCREENSHOT_INTERVAL_MS = 200; +const FRAME_SELECTION_INTERVAL_MS = 1000; /** * HUD component displayed on top of the camera preview for the VideoCapture process. @@ -38,18 +44,17 @@ export function VideoCaptureHUD({ }: VideoCaptureHUDProps) { const [isTutorialDisplayed, setIsTutorialDisplayed] = useState(true); const { t } = useTranslation(); - const { handleError } = useMonitoring(); const { walkaroundPosition, startWalkaround } = useVehicleWalkaround({ alpha }); - const onCaptureVideoFrame = async () => { - try { - const picture = await handle.takePicture(); - console.log('Picture taken :', picture.blob.size); - } catch (err) { - handleError(err); - } + const onFrameSelected = (picture: MonkPicture) => { + console.log('Frame selected :', picture.blob.size); }; + const { onCaptureVideoFrame } = useFrameSelection({ + handle, + frameSelectionInterval: FRAME_SELECTION_INTERVAL_MS, + onFrameSelected, + }); const { isRecording, isRecordingPaused, diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts index eca09ca65..768911579 100644 --- a/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useVehicleWalkaround'; export * from './useVideoRecording'; +export * from './useFrameSelection'; 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..a26ce8221 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useFrameSelection/useFrameSelection.ts @@ -0,0 +1,82 @@ +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 { + /** + * 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({ onCaptureVideoFrame }); +} 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..10d58e4d4 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useFrameSelection.test.ts @@ -0,0 +1,155 @@ +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(); + }); +}); From 9d50fa18e0950c700db64635a032dd0c4435380a Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Fri, 27 Dec 2024 11:17:31 +0100 Subject: [PATCH 13/28] Created useVideoUploadQueue hook --- .../src/components/AppRouter.tsx | 62 +++----- apps/demo-app-video/src/local-config.json | 4 +- .../VideoCapturePage/VideoCapturePage.tsx | 11 +- .../src/__mocks__/@monkvision/network.ts | 11 ++ .../utils/schemas/createInspection.schema.ts | 1 + .../schemas/videoCaptureConfig.schema.ts | 1 + .../src/VideoCapture/VideoCapture.tsx | 5 + .../VideoCaptureHUD/VideoCaptureHUD.tsx | 9 +- .../src/VideoCapture/hooks/index.ts | 1 + .../VideoCapture/hooks/useVideoUploadQueue.ts | 92 ++++++++++++ .../hooks/useVideoUploadQueue.test.ts | 135 ++++++++++++++++++ packages/types/src/config.ts | 6 + 12 files changed, 289 insertions(+), 49 deletions(-) create mode 100644 packages/inspection-capture-web/src/VideoCapture/hooks/useVideoUploadQueue.ts create mode 100644 packages/inspection-capture-web/test/VideoCapture/hooks/useVideoUploadQueue.test.ts diff --git a/apps/demo-app-video/src/components/AppRouter.tsx b/apps/demo-app-video/src/components/AppRouter.tsx index c2b97a98e..126c601a1 100644 --- a/apps/demo-app-video/src/components/AppRouter.tsx +++ b/apps/demo-app-video/src/components/AppRouter.tsx @@ -1,50 +1,34 @@ import { MemoryRouter, Navigate, Route, Routes } from 'react-router-dom'; -import { Page, VideoCapturePage } from '../pages'; +import { AuthGuard } from '@monkvision/common-ui-web'; +import { CreateInspectionPage, LoginPage, Page, VideoCapturePage } from '../pages'; import { App } from './App'; -// export function AppRouter() { -// return ( -// -// -// }> -// } /> -// } /> -// } /> -// } /> -// -// -// -// } -// index -// /> -// -// -// -// } -// index -// /> -// } /> -// } /> -// -// -// -// ); -// } - export function AppRouter() { return ( }> - } /> - } /> - } /> + } /> + } /> + + + + } + index + /> + + + + } + index + /> + } /> diff --git a/apps/demo-app-video/src/local-config.json b/apps/demo-app-video/src/local-config.json index cf8344dd2..70228dee8 100644 --- a/apps/demo-app-video/src/local-config.json +++ b/apps/demo-app-video/src/local-config.json @@ -6,13 +6,15 @@ "fetchFromSearchParams": true, "allowCreateInspection": true, "createInspectionOptions": { - "tasks": ["damage_detection", "wheel_analysis"] + "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", diff --git a/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx index aa8be6808..cf85a26d0 100644 --- a/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx +++ b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx @@ -6,7 +6,8 @@ import styles from './VideoCapturePage.module.css'; export function VideoCapturePage() { const { i18n } = useTranslation(); - const { config } = useMonkAppState({ + const { config, authToken, inspectionId } = useMonkAppState({ + requireInspection: true, requireWorkflow: CaptureWorkflow.VIDEO, }); @@ -15,11 +16,11 @@ export function VideoCapturePage() {
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/src/utils/schemas/createInspection.schema.ts b/documentation/src/utils/schemas/createInspection.schema.ts index 48e464e77..59ecf6a78 100644 --- a/documentation/src/utils/schemas/createInspection.schema.ts +++ b/documentation/src/utils/schemas/createInspection.schema.ts @@ -64,6 +64,7 @@ export const CreateInspectionOptionsSchema = z.object({ vehicle: InspectionCreateVehicleSchema.optional(), useDynamicCrops: z.boolean().optional(), enablePricingV1: z.boolean().optional(), + isVideoCapture: z.boolean().optional(), additionalData: AdditionalDataSchema.optional(), }); diff --git a/documentation/src/utils/schemas/videoCaptureConfig.schema.ts b/documentation/src/utils/schemas/videoCaptureConfig.schema.ts index 4c1761e87..5b71bb46e 100644 --- a/documentation/src/utils/schemas/videoCaptureConfig.schema.ts +++ b/documentation/src/utils/schemas/videoCaptureConfig.schema.ts @@ -6,5 +6,6 @@ export const VideoCaptureAppConfigSchema = z .object({ workflow: z.literal(CaptureWorkflow.VIDEO), minRecordingDuration: z.number().optional(), + maxRetryCount: z.number().optional(), }) .and(SharedCaptureAppConfigSchema); diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx index 821cb26ad..ad1991d99 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx @@ -6,6 +6,7 @@ import { CameraConfig, VideoCaptureAppConfig } from '@monkvision/types'; import { styles } from './VideoCapture.styles'; import { VideoCapturePermissions } from './VideoCapturePermissions'; import { VideoCaptureHUD, VideoCaptureHUDProps } from './VideoCaptureHUD'; +import { useVideoUploadQueue } from './hooks'; /** * Props of the VideoCapture component. @@ -20,6 +21,7 @@ export interface VideoCaptureProps | 'startTasksOnComplete' | 'enforceOrientation' | 'minRecordingDuration' + | 'maxRetryCount' > { /** * The ID of the inspection to add the video frames to. @@ -53,16 +55,19 @@ export function VideoCapture({ startTasksOnComplete, enforceOrientation, minRecordingDuration = 15000, + maxRetryCount = 3, lang, }: VideoCaptureProps) { useI18nSync(lang); const [screen, setScreen] = useState(VideoCaptureScreen.PERMISSIONS); const { requestCompassPermission, alpha } = useDeviceOrientation(); + const { onFrameSelected } = useVideoUploadQueue({ apiConfig, inspectionId, maxRetryCount }); const hudProps: Omit = { alpha, minRecordingDuration, onRecordingComplete: () => console.log('Recording complete!'), + onFrameSelected, }; return ( diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx index 9ef335c8c..96560a2d7 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx @@ -27,6 +27,10 @@ export interface VideoCaptureHUDProps * Callback called when the recording is complete. */ onRecordingComplete?: () => void; + /** + * Callback called when a video frame has been selected by the frame selection hook. + */ + onFrameSelected?: (picture: MonkPicture) => void; } const SCREENSHOT_INTERVAL_MS = 200; @@ -41,15 +45,12 @@ export function VideoCaptureHUD({ alpha, minRecordingDuration, onRecordingComplete, + onFrameSelected, }: VideoCaptureHUDProps) { const [isTutorialDisplayed, setIsTutorialDisplayed] = useState(true); const { t } = useTranslation(); const { walkaroundPosition, startWalkaround } = useVehicleWalkaround({ alpha }); - const onFrameSelected = (picture: MonkPicture) => { - console.log('Frame selected :', picture.blob.size); - }; - const { onCaptureVideoFrame } = useFrameSelection({ handle, frameSelectionInterval: FRAME_SELECTION_INTERVAL_MS, diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts index 768911579..46749e73d 100644 --- a/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useVehicleWalkaround'; export * from './useVideoRecording'; export * from './useFrameSelection'; +export * from './useVideoUploadQueue'; 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..8af5baba9 --- /dev/null +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoUploadQueue.ts @@ -0,0 +1,92 @@ +/* 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 { + /** + * 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({ onFrameSelected }); +} 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..5d4383338 --- /dev/null +++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoUploadQueue.test.ts @@ -0,0 +1,135 @@ +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(); + }); +}); diff --git a/packages/types/src/config.ts b/packages/types/src/config.ts index c7bee0ed7..f8d6ce29d 100644 --- a/packages/types/src/config.ts +++ b/packages/types/src/config.ts @@ -259,6 +259,12 @@ export type VideoCaptureAppConfig = SharedCaptureAppConfig & { * @default 15000 */ minRecordingDuration?: number; + /** + * The maximum number of retries for failed image uploads. + * + * @default 3 + */ + maxRetryCount?: number; }; /** From e170dfb272b40d59444c337d014478f039489147 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Mon, 30 Dec 2024 09:54:57 +0100 Subject: [PATCH 14/28] Created VideoCaptureProcessing component --- .../CreateInspection.styles.ts | 1 + .../test/components/Checkbox.test.tsx | 5 +- .../src/VideoCapture/VideoCapture.tsx | 17 +- .../VideoCaptureHUD/VideoCaptureHUD.tsx | 61 ++++-- .../VideoCaptureTutorial.tsx | 12 +- .../IntroLayoutItem/index.ts | 1 - .../VideoCaptureIntroLayout/index.ts | 3 - .../PageLayoutItem/PageLayoutItem.styles.ts} | 10 +- .../PageLayoutItem/PageLayoutItem.tsx} | 10 +- .../PageLayoutItem/index.ts | 1 + .../VideoCapturePageLayout.styles.ts} | 14 +- .../VideoCapturePageLayout.tsx} | 17 +- .../VideoCapturePageLayout.types.ts} | 12 +- .../VideoCapturePageLayout/index.ts | 3 + .../VideoCapturePermissions.tsx | 10 +- .../VideoCaptureProcessing.styles.ts | 55 ++++++ .../VideoCaptureProcessing.tsx | 51 +++++ .../VideoCaptureProcessing.types.ts | 25 +++ .../VideoCaptureProcessing/index.ts | 2 + .../useFrameSelection/useFrameSelection.ts | 14 +- .../VideoCapture/hooks/useVideoUploadQueue.ts | 14 +- .../src/translations/de.json | 6 + .../src/translations/en.json | 6 + .../src/translations/fr.json | 6 + .../src/translations/nl.json | 6 + .../VideoCaptureTutorial.test.tsx | 26 +-- .../PageLayoutItem.test.tsx} | 16 +- .../VideoCapturePageLayout.test.tsx} | 26 ++- .../VideoCapturePermissions.test.tsx | 42 ++-- .../VideoCaptureProcessing.test.tsx | 179 ++++++++++++++++++ .../hooks/useFrameSelection.test.ts | 17 ++ .../hooks/useVideoUploadQueue.test.ts | 17 ++ 32 files changed, 570 insertions(+), 115 deletions(-) delete mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts delete mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts rename packages/inspection-capture-web/src/VideoCapture/{VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts => VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.styles.ts} (84%) rename packages/inspection-capture-web/src/VideoCapture/{VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx => VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.tsx} (64%) create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/index.ts rename packages/inspection-capture-web/src/VideoCapture/{VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts => VideoCapturePageLayout/VideoCapturePageLayout.styles.ts} (80%) rename packages/inspection-capture-web/src/VideoCapture/{VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx => VideoCapturePageLayout/VideoCapturePageLayout.tsx} (62%) rename packages/inspection-capture-web/src/VideoCapture/{VideoCaptureIntroLayout/VideoCaptureIntroLayout.types.ts => VideoCapturePageLayout/VideoCapturePageLayout.types.ts} (53%) create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/index.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.styles.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.tsx create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/VideoCaptureProcessing.types.ts create mode 100644 packages/inspection-capture-web/src/VideoCapture/VideoCaptureProcessing/index.ts rename packages/inspection-capture-web/test/VideoCapture/{VideoCaptureIntroLayout/IntroLayoutItem.test.tsx => VideoCapturePageLayout/PageLayoutItem.test.tsx} (64%) rename packages/inspection-capture-web/test/VideoCapture/{VideoCaptureIntroLayout/VideoCaptureIntroLayout.test.tsx => VideoCapturePageLayout/VideoCapturePageLayout.test.tsx} (65%) create mode 100644 packages/inspection-capture-web/test/VideoCapture/VideoCaptureProcessing.test.tsx 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/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/inspection-capture-web/src/VideoCapture/VideoCapture.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx index ad1991d99..46e23270b 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapture.tsx @@ -6,7 +6,6 @@ import { CameraConfig, VideoCaptureAppConfig } from '@monkvision/types'; import { styles } from './VideoCapture.styles'; import { VideoCapturePermissions } from './VideoCapturePermissions'; import { VideoCaptureHUD, VideoCaptureHUDProps } from './VideoCaptureHUD'; -import { useVideoUploadQueue } from './hooks'; /** * Props of the VideoCapture component. @@ -32,6 +31,10 @@ export interface VideoCaptureProps * 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. * @@ -56,18 +59,24 @@ export function VideoCapture({ enforceOrientation, minRecordingDuration = 15000, maxRetryCount = 3, + onComplete, lang, }: VideoCaptureProps) { useI18nSync(lang); const [screen, setScreen] = useState(VideoCaptureScreen.PERMISSIONS); const { requestCompassPermission, alpha } = useDeviceOrientation(); - const { onFrameSelected } = useVideoUploadQueue({ apiConfig, inspectionId, maxRetryCount }); + + const handleComplete = () => { + console.log('Recording complete!'); + }; const hudProps: Omit = { alpha, + inspectionId, + maxRetryCount, + apiConfig, minRecordingDuration, - onRecordingComplete: () => console.log('Recording complete!'), - onFrameSelected, + onComplete: handleComplete, }; return ( diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx index 96560a2d7..de912ef69 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { CameraHUDProps } from '@monkvision/camera-web'; import { BackdropDialog } from '@monkvision/common-ui-web'; import { useTranslation } from 'react-i18next'; -import { MonkPicture } from '@monkvision/types'; +import { MonkApiConfig } from '@monkvision/network'; import { styles } from './VideoCaptureHUD.styles'; import { VideoCaptureTutorial } from './VideoCaptureTutorial'; import { VideoCaptureRecording } from './VideoCaptureRecording'; @@ -11,7 +11,9 @@ import { useVehicleWalkaround, useVideoRecording, UseVideoRecordingParams, + useVideoUploadQueue, } from '../hooks'; +import { VideoCaptureProcessing } from '../VideoCaptureProcessing'; /** * Props accepted by the VideoCaptureHUD component. @@ -19,39 +21,62 @@ import { export interface VideoCaptureHUDProps extends CameraHUDProps, 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 alpha value of the device orientaiton. */ alpha: number; /** - * Callback called when the recording is complete. + * The maximum number of retries for failed image uploads. */ - onRecordingComplete?: () => void; + maxRetryCount: number; /** - * Callback called when a video frame has been selected by the frame selection hook. + * Callback called when the inspection capture is complete. */ - onFrameSelected?: (picture: MonkPicture) => void; + onComplete?: () => void; } const SCREENSHOT_INTERVAL_MS = 200; const FRAME_SELECTION_INTERVAL_MS = 1000; +enum VideoCaptureHUDScreen { + TUTORIAL = 'tutorial', + RECORDING = 'recording', + PROCESSING = 'processing', +} + /** * HUD component displayed on top of the camera preview for the VideoCapture process. */ export function VideoCaptureHUD({ handle, cameraPreview, + inspectionId, + apiConfig, alpha, + maxRetryCount, minRecordingDuration, - onRecordingComplete, - onFrameSelected, + onComplete, }: VideoCaptureHUDProps) { - const [isTutorialDisplayed, setIsTutorialDisplayed] = useState(true); + const [screen, setScreen] = useState(VideoCaptureHUDScreen.TUTORIAL); const { t } = useTranslation(); const { walkaroundPosition, startWalkaround } = useVehicleWalkaround({ alpha }); - const { onCaptureVideoFrame } = useFrameSelection({ + const { uploadedFrames, totalUploadingFrames, onFrameSelected } = useVideoUploadQueue({ + apiConfig, + inspectionId, + maxRetryCount, + }); + + const { processedFrames, totalProcessingFrames, onCaptureVideoFrame } = useFrameSelection({ handle, frameSelectionInterval: FRAME_SELECTION_INTERVAL_MS, onFrameSelected, @@ -70,7 +95,7 @@ export function VideoCaptureHUD({ walkaroundPosition, startWalkaround, onCaptureVideoFrame, - onRecordingComplete, + onRecordingComplete: () => setScreen(VideoCaptureHUDScreen.PROCESSING), }); const handleTakePictureClick = () => {}; @@ -79,9 +104,10 @@ export function VideoCaptureHUD({
{cameraPreview}
- {isTutorialDisplayed ? ( - setIsTutorialDisplayed(false)} /> - ) : ( + {screen === VideoCaptureHUDScreen.TUTORIAL && ( + setScreen(VideoCaptureHUDScreen.RECORDING)} /> + )} + {screen === VideoCaptureHUDScreen.RECORDING && ( )} + {screen === VideoCaptureHUDScreen.PROCESSING && ( + + )}
- + - - - + ); } diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts deleted file mode 100644 index 98e0f1d0a..000000000 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { IntroLayoutItem, type IntroLayoutItemProps } from './IntroLayoutItem'; diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts deleted file mode 100644 index f2c533299..000000000 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { VideoCaptureIntroLayout } from './VideoCaptureIntroLayout'; -export { type VideoCaptureIntroLayoutProps } from './VideoCaptureIntroLayout.types'; -export { IntroLayoutItem, type IntroLayoutItemProps } from './IntroLayoutItem'; diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.styles.ts similarity index 84% rename from packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts rename to packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.styles.ts index ea7b691ce..d195e5eb4 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.styles.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.styles.ts @@ -3,7 +3,7 @@ import { Styles } from '@monkvision/types'; import { useMonkTheme, useResponsiveStyle } from '@monkvision/common'; import { IconProps } from '@monkvision/common-ui-web'; -export const INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT = 600; +export const PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT = 600; export const styles: Styles = { container: { @@ -28,7 +28,7 @@ export const styles: Styles = { }, titleSmall: { __media: { - maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, + maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, fontSize: 14, fontWeight: 500, @@ -41,20 +41,20 @@ export const styles: Styles = { }, descriptionSmall: { __media: { - maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, + maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, fontSize: 12, fontWeight: 400, }, }; -interface IntroLayoutItemStyle { +interface PageLayoutItemStyle { iconProps: Partial; titleStyle: CSSProperties; descriptionStyle: CSSProperties; } -export function useIntroLayoutItemStyles(): IntroLayoutItemStyle { +export function usePageLayoutItemStyles(): PageLayoutItemStyle { const { palette } = useMonkTheme(); const { responsive } = useResponsiveStyle(); diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.tsx similarity index 64% rename from packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx rename to packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.tsx index eb8c2a80a..cfd75cc13 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/IntroLayoutItem/IntroLayoutItem.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/PageLayoutItem/PageLayoutItem.tsx @@ -1,10 +1,10 @@ import { Icon, IconName } from '@monkvision/common-ui-web'; -import { styles, useIntroLayoutItemStyles } from './IntroLayoutItem.styles'; +import { styles, usePageLayoutItemStyles } from './PageLayoutItem.styles'; /** - * Props accepted by the IntroLayoutItem component. + * Props accepted by the PageLayoutItem component. */ -export interface IntroLayoutItemProps { +export interface PageLayoutItemProps { /** * The name of the item icon. */ @@ -22,8 +22,8 @@ export interface IntroLayoutItemProps { /** * A custom list item that is displayed in VideoCapture Intro screens. */ -export function IntroLayoutItem({ icon, title, description }: IntroLayoutItemProps) { - const { iconProps, titleStyle, descriptionStyle } = useIntroLayoutItemStyles(); +export function PageLayoutItem({ icon, title, description }: PageLayoutItemProps) { + const { iconProps, titleStyle, descriptionStyle } = usePageLayoutItemStyles(); return (
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/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.styles.ts similarity index 80% rename from packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts rename to packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.styles.ts index ea8642550..a18fd62a8 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.styles.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.styles.ts @@ -2,8 +2,8 @@ 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 { VideoCaptureIntroLayoutProps } from './VideoCaptureIntroLayout.types'; -import { INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT } from './IntroLayoutItem/IntroLayoutItem.styles'; +import { VideoCapturePageLayoutProps } from './VideoCapturePageLayout.types'; +import { PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT } from './PageLayoutItem/PageLayoutItem.styles'; export const styles: Styles = { container: { @@ -23,7 +23,7 @@ export const styles: Styles = { }, logoSmall: { __media: { - maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, + maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, display: 'none', }, @@ -35,7 +35,7 @@ export const styles: Styles = { }, titleSmall: { __media: { - maxHeight: INTRO_LAYOUT_MAX_HEIGHT_BREAKPOINT, + maxHeight: PAGE_LAYOUT_MAX_HEIGHT_BREAKPOINT, }, fontSize: 20, fontWeight: 700, @@ -61,15 +61,15 @@ export const styles: Styles = { }, }; -interface VideoCaptureIntroLayoutStyles { +interface VideoCapturePageLayoutStyles { logoProps: Partial; containerStyle: CSSProperties; titleStyle: CSSProperties; } -export function useVideoCaptureIntroLayoutStyles({ +export function useVideoCapturePageLayoutStyles({ showBackdrop, -}: Pick): VideoCaptureIntroLayoutStyles { +}: Required>): VideoCapturePageLayoutStyles { const { palette } = useMonkTheme(); const { responsive } = useResponsiveStyle(); diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.tsx similarity index 62% rename from packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx rename to packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.tsx index 970d1dc31..593af247a 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureIntroLayout/VideoCaptureIntroLayout.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCapturePageLayout/VideoCapturePageLayout.tsx @@ -2,27 +2,28 @@ 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, useVideoCaptureIntroLayoutStyles } from './VideoCaptureIntroLayout.styles'; -import { VideoCaptureIntroLayoutProps } from './VideoCaptureIntroLayout.types'; +import { styles, useVideoCapturePageLayoutStyles } from './VideoCapturePageLayout.styles'; +import { VideoCapturePageLayoutProps } from './VideoCapturePageLayout.types'; /** - * This component is used to display the same layout for every "introduction" screen for the VideoCapture process (the + * 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 VideoCaptureIntroLayout({ - showBackdrop, +export function VideoCapturePageLayout({ + showBackdrop = false, + showTitle = true, confirmButtonProps, children, -}: PropsWithChildren) { +}: PropsWithChildren) { const { t } = useTranslation(); - const { logoProps, containerStyle, titleStyle } = useVideoCaptureIntroLayoutStyles({ + const { logoProps, containerStyle, titleStyle } = useVideoCapturePageLayoutStyles({ showBackdrop, }); return (
-
{t('video.introduction.title')}
+ {showTitle &&
{t('video.introduction.title')}
}
{children}
); diff --git a/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts index 684a44ad9..efc37dee8 100644 --- a/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts +++ b/packages/common-ui-web/src/components/RecordVideoButton/RecordVideoButton.types.ts @@ -14,6 +14,16 @@ export interface MonkRecordVideoButtonProps { * 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. */ diff --git a/packages/common-ui-web/test/components/RecordVideoButton.test.tsx b/packages/common-ui-web/test/components/RecordVideoButton.test.tsx index c0bae5715..168578091 100644 --- a/packages/common-ui-web/test/components/RecordVideoButton.test.tsx +++ b/packages/common-ui-web/test/components/RecordVideoButton.test.tsx @@ -51,6 +51,15 @@ describe('RecordVideoButton component', () => { 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); From 91a041710c8e2e50ee6849a25a229654fa61c9bb Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Tue, 7 Jan 2025 11:54:34 +0100 Subject: [PATCH 27/28] Added video recording tooltips --- .../VideoCaptureHUD/VideoCaptureHUD.tsx | 14 ++++++ .../VideoCaptureRecording.tsx | 9 +++- .../VideoCaptureRecording.types.ts | 4 ++ .../VideoCaptureRecordingStyles.ts | 11 ++++- .../VideoCapture/hooks/useVideoRecording.ts | 41 +++++++++++++++-- .../src/translations/de.json | 4 ++ .../src/translations/en.json | 4 ++ .../src/translations/fr.json | 4 ++ .../src/translations/nl.json | 4 ++ .../VideoCaptureHUD/VideoCaptureHUD.test.tsx | 45 +++++++++++++++++++ .../VideoCaptureRecording.test.tsx | 31 +++++++++++++ .../hooks/useVideoRecording.test.ts | 42 ++++++++++++++++- 12 files changed, 206 insertions(+), 7 deletions(-) diff --git a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx index b509de126..5cd0ee664 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.tsx @@ -17,6 +17,7 @@ import { useVideoRecording, UseVideoRecordingParams, useVideoUploadQueue, + VideoRecordingTooltip, } from '../hooks'; import { VideoCaptureProcessing } from '../VideoCaptureProcessing'; import { OrientationEnforcer } from '../../components'; @@ -81,6 +82,17 @@ function getFastMovementsWarningMessage(type: FastMovementType | null): string { } } +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. */ @@ -126,6 +138,7 @@ export function VideoCaptureHUD({ recordingDurationMs, pauseRecording, resumeRecording, + tooltip, } = useVideoRecording({ isRecording, setIsRecording, @@ -174,6 +187,7 @@ export function VideoCaptureHUD({ recordingDurationMs={recordingDurationMs} onClickRecordVideo={onClickRecordVideo} onClickTakePicture={handleTakePictureClick} + tooltip={t(getTooltipLabel(tooltip))} /> )} {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 index 5d7bc7731..b7ab52463 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.tsx @@ -25,6 +25,7 @@ export function VideoCaptureRecording({ recordingDurationMs, onClickRecordVideo, onClickTakePicture, + tooltip, }: VideoCaptureRecordingProps) { const { container, @@ -34,6 +35,7 @@ export function VideoCaptureRecording({ takePictureFlash, walkaroundIndicator, showTakePictureFlash, + tooltipPosition, } = useVideoCaptureRecordingStyles({ isRecording }); const handleTakePictureClick = () => { @@ -52,7 +54,12 @@ export function VideoCaptureRecording({
- +
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 index 72afb8f4b..d77efbe26 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecording.types.ts @@ -26,4 +26,8 @@ export interface VideoCaptureRecordingProps { * 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 index 6ba93966c..2cfd548a0 100644 --- a/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts +++ b/packages/inspection-capture-web/src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording/VideoCaptureRecordingStyles.ts @@ -1,6 +1,12 @@ import { Styles } from '@monkvision/types'; -import { useIsMounted, useMonkTheme, useResponsiveStyle } from '@monkvision/common'; +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 = { @@ -72,6 +78,8 @@ export function useVideoCaptureRecordingStyles({ const [isTakePictureFlashVisible, setTakePictureFlashVisible] = useState(false); const { palette } = useMonkTheme(); const { responsive } = useResponsiveStyle(); + const { isPortrait } = useWindowDimensions(); + const isMounted = useIsMounted(); const showTakePictureFlash = () => { @@ -109,5 +117,6 @@ export function useVideoCaptureRecordingStyles({ ...(isRecording ? {} : styles['walkaroundIndicatorDisabled']), }, showTakePictureFlash, + tooltipPosition: (isPortrait ? 'up' : 'left') as RecordVideoButtonProps['tooltipPosition'], }; } diff --git a/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts index 2b69884b4..f139eaf16 100644 --- a/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts +++ b/packages/inspection-capture-web/src/VideoCapture/hooks/useVideoRecording.ts @@ -4,6 +4,21 @@ 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. */ @@ -77,6 +92,10 @@ export interface VideoRecordingHandle { * 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; @@ -100,6 +119,8 @@ export function useVideoRecording({ 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( @@ -153,6 +174,7 @@ export function useVideoRecording({ setRecordingStartTimestamp(Date.now()); setIsRecording(true); startWalkaround(); + setTooltip(null); } }, [ isRecording, @@ -186,12 +208,24 @@ export function useVideoRecording({ ); useEffect(() => { - if (isViolatingEnforcedOrientation) { + if (isViolatingEnforcedOrientation && isRecording) { + setOrientationPause(true); pauseRecording(); - } else { + } else if (!isViolatingEnforcedOrientation && orientationPause) { + setOrientationPause(false); resumeRecording(); } - }, [isViolatingEnforcedOrientation]); + }, [isViolatingEnforcedOrientation, isRecording, orientationPause]); + + useEffect(() => { + if (isRecording) { + if (walkaroundPosition > 315) { + setTooltip(VideoRecordingTooltip.END); + } else { + setTooltip(null); + } + } + }, [walkaroundPosition, isRecording]); return { isRecordingPaused, @@ -202,5 +236,6 @@ export function useVideoRecording({ isDiscardDialogDisplayed, pauseRecording, resumeRecording, + tooltip, }; } diff --git a/packages/inspection-capture-web/src/translations/de.json b/packages/inspection-capture-web/src/translations/de.json index bb4f3caef..fe0376ebc 100644 --- a/packages/inspection-capture-web/src/translations/de.json +++ b/packages/inspection-capture-web/src/translations/de.json @@ -81,6 +81,10 @@ "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": { diff --git a/packages/inspection-capture-web/src/translations/en.json b/packages/inspection-capture-web/src/translations/en.json index 6373431d8..33f7c9bf1 100644 --- a/packages/inspection-capture-web/src/translations/en.json +++ b/packages/inspection-capture-web/src/translations/en.json @@ -81,6 +81,10 @@ "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": { diff --git a/packages/inspection-capture-web/src/translations/fr.json b/packages/inspection-capture-web/src/translations/fr.json index 842aec1ba..7208579fc 100644 --- a/packages/inspection-capture-web/src/translations/fr.json +++ b/packages/inspection-capture-web/src/translations/fr.json @@ -81,6 +81,10 @@ "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": { diff --git a/packages/inspection-capture-web/src/translations/nl.json b/packages/inspection-capture-web/src/translations/nl.json index 768dc2972..27a5bfb34 100644 --- a/packages/inspection-capture-web/src/translations/nl.json +++ b/packages/inspection-capture-web/src/translations/nl.json @@ -81,6 +81,10 @@ "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": { diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx index 48a787afa..3fc1ef1f9 100644 --- a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureHUD.test.tsx @@ -29,6 +29,7 @@ jest.mock('../../../src/VideoCapture/hooks', () => ({ recordingDurationMs: 234, pauseRecording: jest.fn(), resumeRecording: jest.fn(), + tooltip: null, })), })); @@ -49,6 +50,7 @@ import { useVehicleWalkaround, useVideoRecording, useVideoUploadQueue, + VideoRecordingTooltip, } from '../../../src/VideoCapture/hooks'; const CAMERA_TEST_ID = 'test-id'; @@ -220,6 +222,49 @@ describe('VideoCaptureHUD component', () => { 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(); diff --git a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx index 049bd5846..eb41c1dd8 100644 --- a/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx +++ b/packages/inspection-capture-web/test/VideoCapture/VideoCaptureHUD/VideoCaptureRecording.test.tsx @@ -10,6 +10,7 @@ import { VideoCaptureRecording, VideoCaptureRecordingProps, } from '../../../src/VideoCapture/VideoCaptureHUD/VideoCaptureRecording'; +import { useWindowDimensions } from '@monkvision/common'; const VEHICLE_WALKAROUND_INDICATOR_CONTAINER_TEST_ID = 'walkaround-indicator-container'; @@ -21,6 +22,7 @@ function createProps(): VideoCaptureRecordingProps { recordingDurationMs: 75800, onClickRecordVideo: jest.fn(), onClickTakePicture: jest.fn(), + tooltip: 'test-tooltip', }; } @@ -90,6 +92,35 @@ describe('VideoCaptureRecording component', () => { 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(); diff --git a/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts index a5b6a6d8c..bad7659e9 100644 --- a/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts +++ b/packages/inspection-capture-web/test/VideoCapture/hooks/useVideoRecording.test.ts @@ -1,4 +1,8 @@ -import { useVideoRecording, UseVideoRecordingParams } from '../../../src/VideoCapture/hooks'; +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'; @@ -12,7 +16,7 @@ function createProps(): UseVideoRecordingParams { setIsRecording: jest.fn((param) => { isRecording = typeof param === 'boolean' ? param : param(isRecording); }), - walkaroundPosition: 350, + walkaroundPosition: 300, startWalkaround: jest.fn(), screenshotInterval: 200, minRecordingDuration: 5000, @@ -206,4 +210,38 @@ describe('useVideoRecording hook', () => { 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(); + }); }); From 3adbd8111c061a6464d909b40f4e84920ae0ca20 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Tue, 7 Jan 2025 12:01:47 +0100 Subject: [PATCH 28/28] Added redirection to VM at the end of the recording process --- apps/demo-app-video/.env-cmdrc.json | 15 ++++++++++----- .../pages/VideoCapturePage/VideoCapturePage.tsx | 6 ++---- .../pages/VideoCapturePage/inspectionReport.ts | 11 +++++++++++ 3 files changed, 23 insertions(+), 9 deletions(-) create mode 100644 apps/demo-app-video/src/pages/VideoCapturePage/inspectionReport.ts diff --git a/apps/demo-app-video/.env-cmdrc.json b/apps/demo-app-video/.env-cmdrc.json index 83bc50d45..4e850141f 100644 --- a/apps/demo-app-video/.env-cmdrc.json +++ b/apps/demo-app-video/.env-cmdrc.json @@ -10,7 +10,8 @@ "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_SENTRY_DEBUG": "true", + "REACT_APP_INSPECTION_REPORT_URL": "https://demo-capture.preview.monk.ai" }, "development": { "REACT_APP_ENVIRONMENT": "development", @@ -18,7 +19,8 @@ "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_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", @@ -26,7 +28,8 @@ "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_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", @@ -34,7 +37,8 @@ "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_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", @@ -42,6 +46,7 @@ "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_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/src/pages/VideoCapturePage/VideoCapturePage.tsx b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx index 7e3dcfd35..634664d04 100644 --- a/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx +++ b/apps/demo-app-video/src/pages/VideoCapturePage/VideoCapturePage.tsx @@ -2,20 +2,18 @@ import { useTranslation } from 'react-i18next'; import { VideoCapture } from '@monkvision/inspection-capture-web'; import { useMonkAppState } from '@monkvision/common'; import { CaptureWorkflow } from '@monkvision/types'; -import { useNavigate } from 'react-router-dom'; -import { Page } from '../pages'; import styles from './VideoCapturePage.module.css'; +import { createInspectionReportLink } from './inspectionReport'; export function VideoCapturePage() { const { i18n } = useTranslation(); - const navigate = useNavigate(); const { config, authToken, inspectionId } = useMonkAppState({ requireInspection: true, requireWorkflow: CaptureWorkflow.VIDEO, }); const handleComplete = () => { - navigate(Page.INSPECTION_COMPLETE); + window.location.href = createInspectionReportLink(authToken, inspectionId, i18n.language); }; return ( 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}`; +}