feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,32 @@
import { CameraType, CameraCapturedPicture, ImageSize, ImageType, WebCameraSettings, CameraPictureOptions } from '../legacy/Camera.types';
interface ConstrainLongRange {
max?: number;
min?: number;
exact?: number;
ideal?: number;
}
export declare function getImageSize(videoWidth: number, videoHeight: number, scale: number): ImageSize;
export declare function toDataURL(canvas: HTMLCanvasElement, imageType: ImageType, quality: number): string;
export declare function hasValidConstraints(preferredCameraType?: CameraType, width?: number | ConstrainLongRange, height?: number | ConstrainLongRange): boolean;
export declare function captureImageData(video: HTMLVideoElement | null, pictureOptions?: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'>): ImageData | null;
export declare function captureImageContext(video: HTMLVideoElement, { scale, isImageMirror }: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'>): HTMLCanvasElement;
export declare function captureImage(video: HTMLVideoElement, pictureOptions: CameraPictureOptions): string;
export declare function getIdealConstraints(preferredCameraType: CameraType, width?: number | ConstrainLongRange, height?: number | ConstrainLongRange): MediaStreamConstraints;
/**
* Invoke getStreamDevice a second time with the opposing camera type if the preferred type cannot be retrieved.
*
* @param preferredCameraType
* @param preferredWidth
* @param preferredHeight
*/
export declare function getPreferredStreamDevice(preferredCameraType: CameraType, preferredWidth?: number | ConstrainLongRange, preferredHeight?: number | ConstrainLongRange): Promise<MediaStream>;
export declare function getStreamDevice(preferredCameraType: CameraType, preferredWidth?: number | ConstrainLongRange, preferredHeight?: number | ConstrainLongRange): Promise<MediaStream>;
export declare function isWebKit(): boolean;
export declare function compareStreams(a: MediaStream | null, b: MediaStream | null): boolean;
export declare function capture(video: HTMLVideoElement, settings: MediaTrackSettings, config: CameraPictureOptions): CameraCapturedPicture;
export declare function syncTrackCapabilities(cameraType: CameraType, stream: MediaStream | null, settings?: WebCameraSettings): Promise<void>;
export declare function stopMediaStream(stream: MediaStream | null): void;
export declare function setVideoSource(video: HTMLVideoElement, stream: MediaStream | MediaSource | Blob | null): void;
export declare function isCapabilityAvailable(video: HTMLVideoElement, keyName: string): boolean;
export {};
//# sourceMappingURL=WebCameraUtils.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"WebCameraUtils.d.ts","sourceRoot":"","sources":["../../src/web/WebCameraUtils.ts"],"names":[],"mappings":"AAMA,OAAO,EACL,UAAU,EACV,qBAAqB,EACrB,SAAS,EACT,SAAS,EACT,iBAAiB,EACjB,oBAAoB,EACrB,MAAM,wBAAwB,CAAC;AAEhC,UAAU,kBAAkB;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,CAS9F;AAED,wBAAgB,SAAS,CACvB,MAAM,EAAE,iBAAiB,EACzB,SAAS,EAAE,SAAS,EACpB,OAAO,EAAE,MAAM,GACd,MAAM,CAkBR;AAED,wBAAgB,mBAAmB,CACjC,mBAAmB,CAAC,EAAE,UAAU,EAChC,KAAK,CAAC,EAAE,MAAM,GAAG,kBAAkB,EACnC,MAAM,CAAC,EAAE,MAAM,GAAG,kBAAkB,GACnC,OAAO,CAET;AAmBD,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,gBAAgB,GAAG,IAAI,EAC9B,cAAc,GAAE,IAAI,CAAC,oBAAoB,EAAE,OAAO,GAAG,eAAe,CAAM,GACzE,SAAS,GAAG,IAAI,CAalB;AAED,wBAAgB,mBAAmB,CACjC,KAAK,EAAE,gBAAgB,EACvB,EAAE,KAAS,EAAE,aAAqB,EAAE,EAAE,IAAI,CAAC,oBAAoB,EAAE,OAAO,GAAG,eAAe,CAAC,GAC1F,iBAAiB,CAyBnB;AAED,wBAAgB,YAAY,CAC1B,KAAK,EAAE,gBAAgB,EACvB,cAAc,EAAE,oBAAoB,GACnC,MAAM,CAKR;AASD,wBAAgB,mBAAmB,CACjC,mBAAmB,EAAE,UAAU,EAC/B,KAAK,CAAC,EAAE,MAAM,GAAG,kBAAkB,EACnC,MAAM,CAAC,EAAE,MAAM,GAAG,kBAAkB,GACnC,sBAAsB,CAoCxB;AAMD;;;;;;GAMG;AACH,wBAAsB,wBAAwB,CAC5C,mBAAmB,EAAE,UAAU,EAC/B,cAAc,CAAC,EAAE,MAAM,GAAG,kBAAkB,EAC5C,eAAe,CAAC,EAAE,MAAM,GAAG,kBAAkB,GAC5C,OAAO,CAAC,WAAW,CAAC,CAatB;AAED,wBAAsB,eAAe,CACnC,mBAAmB,EAAE,UAAU,EAC/B,cAAc,CAAC,EAAE,MAAM,GAAG,kBAAkB,EAC5C,eAAe,CAAC,EAAE,MAAM,GAAG,kBAAkB,GAC5C,OAAO,CAAC,WAAW,CAAC,CAQtB;AAED,wBAAgB,QAAQ,IAAI,OAAO,CAElC;AAED,wBAAgB,cAAc,CAAC,CAAC,EAAE,WAAW,GAAG,IAAI,EAAE,CAAC,EAAE,WAAW,GAAG,IAAI,GAAG,OAAO,CAOpF;AAED,wBAAgB,OAAO,CACrB,KAAK,EAAE,gBAAgB,EACvB,QAAQ,EAAE,kBAAkB,EAC5B,MAAM,EAAE,oBAAoB,GAC3B,qBAAqB,CAqBvB;AAED,wBAAsB,qBAAqB,CACzC,UAAU,EAAE,UAAU,EACtB,MAAM,EAAE,WAAW,GAAG,IAAI,EAC1B,QAAQ,GAAE,iBAAsB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAMf;AAiFD,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,QAazD;AAED,wBAAgB,cAAc,CAC5B,KAAK,EAAE,gBAAgB,EACvB,MAAM,EAAE,WAAW,GAAG,WAAW,GAAG,IAAI,GAAG,IAAI,GAC9C,IAAI,CAkBN;AAED,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CASvF"}

View File

@@ -0,0 +1,309 @@
/* eslint-env browser */
import invariant from 'invariant';
import * as CapabilityUtils from './WebCapabilityUtils';
import { CameraTypeToFacingMode, ImageTypeFormat, MinimumConstraints } from './WebConstants';
import { requestUserMediaAsync } from './WebUserMediaManager';
import { CameraType, ImageType, } from '../legacy/Camera.types';
export function getImageSize(videoWidth, videoHeight, scale) {
const width = videoWidth * scale;
const ratio = videoWidth / width;
const height = videoHeight / ratio;
return {
width,
height,
};
}
export function toDataURL(canvas, imageType, quality) {
invariant(Object.values(ImageType).includes(imageType), `expo-camera: ${imageType} is not a valid ImageType. Expected a string from: ${Object.values(ImageType).join(', ')}`);
const format = ImageTypeFormat[imageType];
if (imageType === ImageType.jpg) {
invariant(quality <= 1 && quality >= 0, `expo-camera: ${quality} is not a valid image quality. Expected a number from 0...1`);
return canvas.toDataURL(format, quality);
}
else {
return canvas.toDataURL(format);
}
}
export function hasValidConstraints(preferredCameraType, width, height) {
return preferredCameraType !== undefined && width !== undefined && height !== undefined;
}
function ensureCameraPictureOptions(config) {
const captureOptions = {
scale: 1,
imageType: ImageType.png,
isImageMirror: false,
};
for (const key in config) {
if (key in config && config[key] !== undefined && key in captureOptions) {
captureOptions[key] = config[key];
}
}
return captureOptions;
}
const DEFAULT_QUALITY = 0.92;
export function captureImageData(video, pictureOptions = {}) {
if (!video || video.readyState !== video.HAVE_ENOUGH_DATA) {
return null;
}
const canvas = captureImageContext(video, pictureOptions);
const context = canvas.getContext('2d', { alpha: false });
if (!context || !canvas.width || !canvas.height) {
return null;
}
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
return imageData;
}
export function captureImageContext(video, { scale = 1, isImageMirror = false }) {
const { videoWidth, videoHeight } = video;
const { width, height } = getImageSize(videoWidth, videoHeight, scale);
// Build the canvas size and draw the camera image to the context from video
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d', { alpha: false });
if (!context) {
// Should never be called
throw new Error('Context is not defined');
}
// sharp image details
// context.imageSmoothingEnabled = false;
// Flip horizontally (as css transform: rotateY(180deg))
if (isImageMirror) {
context.setTransform(-1, 0, 0, 1, canvas.width, 0);
}
context.drawImage(video, 0, 0, width, height);
return canvas;
}
export function captureImage(video, pictureOptions) {
const config = ensureCameraPictureOptions(pictureOptions);
const canvas = captureImageContext(video, config);
const { imageType, quality = DEFAULT_QUALITY } = config;
return toDataURL(canvas, imageType, quality);
}
function getSupportedConstraints() {
if (navigator.mediaDevices && navigator.mediaDevices.getSupportedConstraints) {
return navigator.mediaDevices.getSupportedConstraints();
}
return null;
}
export function getIdealConstraints(preferredCameraType, width, height) {
const preferredConstraints = {
audio: false,
video: {},
};
if (hasValidConstraints(preferredCameraType, width, height)) {
return MinimumConstraints;
}
const supports = getSupportedConstraints();
// TODO(Bacon): Test this
if (!supports || !supports.facingMode || !supports.width || !supports.height) {
return MinimumConstraints;
}
if (preferredCameraType && Object.values(CameraType).includes(preferredCameraType)) {
const facingMode = CameraTypeToFacingMode[preferredCameraType];
if (isWebKit()) {
const key = facingMode === 'user' ? 'exact' : 'ideal';
preferredConstraints.video.facingMode = {
[key]: facingMode,
};
}
else {
preferredConstraints.video.facingMode = {
ideal: CameraTypeToFacingMode[preferredCameraType],
};
}
}
if (isMediaTrackConstraints(preferredConstraints.video)) {
preferredConstraints.video.width = width;
preferredConstraints.video.height = height;
}
return preferredConstraints;
}
function isMediaTrackConstraints(input) {
return input && typeof input.video !== 'boolean';
}
/**
* Invoke getStreamDevice a second time with the opposing camera type if the preferred type cannot be retrieved.
*
* @param preferredCameraType
* @param preferredWidth
* @param preferredHeight
*/
export async function getPreferredStreamDevice(preferredCameraType, preferredWidth, preferredHeight) {
try {
return await getStreamDevice(preferredCameraType, preferredWidth, preferredHeight);
}
catch (error) {
// A hack on desktop browsers to ensure any camera is used.
// eslint-disable-next-line no-undef
if (error instanceof OverconstrainedError && error.constraint === 'facingMode') {
const nextCameraType = preferredCameraType === CameraType.back ? CameraType.front : CameraType.back;
return await getStreamDevice(nextCameraType, preferredWidth, preferredHeight);
}
throw error;
}
}
export async function getStreamDevice(preferredCameraType, preferredWidth, preferredHeight) {
const constraints = getIdealConstraints(preferredCameraType, preferredWidth, preferredHeight);
const stream = await requestUserMediaAsync(constraints);
return stream;
}
export function isWebKit() {
return /WebKit/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent);
}
export function compareStreams(a, b) {
if (!a || !b) {
return false;
}
const settingsA = a.getTracks()[0].getSettings();
const settingsB = b.getTracks()[0].getSettings();
return settingsA.deviceId === settingsB.deviceId;
}
export function capture(video, settings, config) {
const base64 = captureImage(video, config);
const capturedPicture = {
uri: base64,
base64,
width: 0,
height: 0,
};
if (settings) {
const { width = 0, height = 0 } = settings;
capturedPicture.width = width;
capturedPicture.height = height;
capturedPicture.exif = settings;
}
if (config.onPictureSaved) {
config.onPictureSaved(capturedPicture);
}
return capturedPicture;
}
export async function syncTrackCapabilities(cameraType, stream, settings = {}) {
if (stream?.getVideoTracks) {
await Promise.all(stream.getVideoTracks().map((track) => onCapabilitiesReady(cameraType, track, settings)));
}
}
// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints
async function onCapabilitiesReady(cameraType, track, settings = {}) {
if (typeof track.getCapabilities !== 'function') {
return;
}
const capabilities = track.getCapabilities();
// Create an empty object because if you set a constraint that isn't available an error will be thrown.
const constraints = {};
// TODO(Bacon): Add `pointsOfInterest` support
const clampedValues = [
'exposureCompensation',
'colorTemperature',
'iso',
'brightness',
'contrast',
'saturation',
'sharpness',
'focusDistance',
'zoom',
];
for (const property of clampedValues) {
if (capabilities[property]) {
constraints[property] = convertNormalizedSetting(capabilities[property], settings[property]);
}
}
function validatedInternalConstrainedValue(constraintKey, settingsKey, converter) {
const convertedSetting = converter(settings[settingsKey]);
return validatedConstrainedValue({
constraintKey,
settingsKey,
convertedSetting,
capabilities,
settings,
cameraType,
});
}
if (capabilities.focusMode && settings.autoFocus !== undefined) {
constraints.focusMode = validatedInternalConstrainedValue('focusMode', 'autoFocus', CapabilityUtils.convertAutoFocusJSONToNative);
}
if (capabilities.torch && settings.flashMode !== undefined) {
constraints.torch = validatedInternalConstrainedValue('torch', 'flashMode', CapabilityUtils.convertFlashModeJSONToNative);
}
if (capabilities.whiteBalanceMode && settings.whiteBalance !== undefined) {
constraints.whiteBalanceMode = validatedInternalConstrainedValue('whiteBalanceMode', 'whiteBalance', CapabilityUtils.convertWhiteBalanceJSONToNative);
}
try {
await track.applyConstraints({ advanced: [constraints] });
}
catch (error) {
if (__DEV__)
console.warn('Failed to apply constraints', error);
}
}
export function stopMediaStream(stream) {
if (!stream) {
return;
}
if (stream.getAudioTracks) {
stream.getAudioTracks().forEach((track) => track.stop());
}
if (stream.getVideoTracks) {
stream.getVideoTracks().forEach((track) => track.stop());
}
if (isMediaStreamTrack(stream)) {
stream.stop();
}
}
export function setVideoSource(video, stream) {
const createObjectURL = window.URL.createObjectURL ?? window.webkitURL.createObjectURL;
if (typeof video.srcObject !== 'undefined') {
video.srcObject = stream;
}
else if (typeof video.mozSrcObject !== 'undefined') {
video.mozSrcObject = stream;
}
else if (stream && createObjectURL) {
video.src = createObjectURL(stream);
}
if (!stream) {
const revokeObjectURL = window.URL.revokeObjectURL ?? window.webkitURL.revokeObjectURL;
const source = video.src ?? video.srcObject ?? video.mozSrcObject;
if (revokeObjectURL && typeof source === 'string') {
revokeObjectURL(source);
}
}
}
export function isCapabilityAvailable(video, keyName) {
const stream = video.srcObject;
if (stream instanceof MediaStream) {
const videoTrack = stream.getVideoTracks()[0];
return videoTrack.getCapabilities?.()?.[keyName];
}
return false;
}
function isMediaStreamTrack(input) {
return typeof input.stop === 'function';
}
function convertNormalizedSetting(range, value) {
if (!value) {
return;
}
// convert the normalized incoming setting to the native camera zoom range
const converted = convertRange(value, [range.min, range.max]);
// clamp value so we don't get an error
return Math.min(range.max, Math.max(range.min, converted));
}
function convertRange(value, r2, r1 = [0, 1]) {
return ((value - r1[0]) * (r2[1] - r2[0])) / (r1[1] - r1[0]) + r2[0];
}
function validatedConstrainedValue(props) {
const { constraintKey, settingsKey, convertedSetting, capabilities, settings, cameraType } = props;
const setting = settings[settingsKey];
if (Array.isArray(capabilities[constraintKey]) &&
convertedSetting &&
!capabilities[constraintKey].includes(convertedSetting)) {
if (__DEV__) {
// Only warn in dev mode.
console.warn(` { ${settingsKey}: "${setting}" } (converted to "${convertedSetting}" in the browser) is not supported for camera type "${cameraType}" in your browser. Using the default value instead.`);
}
return undefined;
}
return convertedSetting;
}
//# sourceMappingURL=WebCameraUtils.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,4 @@
export declare function convertFlashModeJSONToNative(input: string): boolean;
export declare function convertWhiteBalanceJSONToNative(input: string): MeteringMode | undefined;
export declare function convertAutoFocusJSONToNative(input: string): MeteringMode | undefined;
//# sourceMappingURL=WebCapabilityUtils.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"WebCapabilityUtils.d.ts","sourceRoot":"","sources":["../../src/web/WebCapabilityUtils.ts"],"names":[],"mappings":"AAGA,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAUnE;AAED,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAYvF;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAYpF"}

View File

@@ -0,0 +1,41 @@
/*
* Native web camera (Android) has a torch: boolean
*/
export function convertFlashModeJSONToNative(input) {
switch (input) {
case 'torch':
return true;
case 'on':
case 'off':
case 'auto':
default:
return false;
}
}
export function convertWhiteBalanceJSONToNative(input) {
switch (input) {
case 'on':
case 'auto':
return 'continuous';
case 'off':
return 'none';
case 'singleShot':
return 'single-shot';
default:
return undefined;
}
}
export function convertAutoFocusJSONToNative(input) {
switch (input) {
case 'on':
case 'auto':
return 'continuous';
case 'off':
return 'manual';
case 'singleShot':
return 'single-shot';
default:
return undefined;
}
}
//# sourceMappingURL=WebCapabilityUtils.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"WebCapabilityUtils.js","sourceRoot":"","sources":["../../src/web/WebCapabilityUtils.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,UAAU,4BAA4B,CAAC,KAAa;IACxD,QAAQ,KAAK,EAAE;QACb,KAAK,OAAO;YACV,OAAO,IAAI,CAAC;QACd,KAAK,IAAI,CAAC;QACV,KAAK,KAAK,CAAC;QACX,KAAK,MAAM,CAAC;QACZ;YACE,OAAO,KAAK,CAAC;KAChB;AACH,CAAC;AAED,MAAM,UAAU,+BAA+B,CAAC,KAAa;IAC3D,QAAQ,KAAK,EAAE;QACb,KAAK,IAAI,CAAC;QACV,KAAK,MAAM;YACT,OAAO,YAAY,CAAC;QACtB,KAAK,KAAK;YACR,OAAO,MAAM,CAAC;QAChB,KAAK,YAAY;YACf,OAAO,aAAa,CAAC;QACvB;YACE,OAAO,SAAS,CAAC;KACpB;AACH,CAAC;AAED,MAAM,UAAU,4BAA4B,CAAC,KAAa;IACxD,QAAQ,KAAK,EAAE;QACb,KAAK,IAAI,CAAC;QACV,KAAK,MAAM;YACT,OAAO,YAAY,CAAC;QACtB,KAAK,KAAK;YACR,OAAO,QAAQ,CAAC;QAClB,KAAK,YAAY;YACf,OAAO,aAAa,CAAC;QACvB;YACE,OAAO,SAAS,CAAC;KACpB;AACH,CAAC","sourcesContent":["/*\n * Native web camera (Android) has a torch: boolean\n */\nexport function convertFlashModeJSONToNative(input: string): boolean {\n switch (input) {\n case 'torch':\n return true;\n case 'on':\n case 'off':\n case 'auto':\n default:\n return false;\n }\n}\n\nexport function convertWhiteBalanceJSONToNative(input: string): MeteringMode | undefined {\n switch (input) {\n case 'on':\n case 'auto':\n return 'continuous';\n case 'off':\n return 'none';\n case 'singleShot':\n return 'single-shot';\n default:\n return undefined;\n }\n}\n\nexport function convertAutoFocusJSONToNative(input: string): MeteringMode | undefined {\n switch (input) {\n case 'on':\n case 'auto':\n return 'continuous';\n case 'off':\n return 'manual';\n case 'singleShot':\n return 'single-shot';\n default:\n return undefined;\n }\n}\n"]}

View File

@@ -0,0 +1,23 @@
import { CameraType } from '../legacy/Camera.types';
export declare const VIDEO_ASPECT_RATIOS: {
'3840x2160': number;
'1920x1080': number;
'1280x720': number;
'640x480': number;
'352x288': number;
};
export declare const PictureSizes: string[];
export declare const ImageTypeFormat: {
jpg: string;
png: string;
};
export declare const MinimumConstraints: MediaStreamConstraints;
export declare const CameraTypeToFacingMode: {
front: string;
back: string;
};
export declare const FacingModeToCameraType: {
user: CameraType;
environment: CameraType;
};
//# sourceMappingURL=WebConstants.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"WebConstants.d.ts","sourceRoot":"","sources":["../../src/web/WebConstants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAa,MAAM,wBAAwB,CAAC;AAG/D,eAAO,MAAM,mBAAmB;;;;;;CAM/B,CAAC;AAEF,eAAO,MAAM,YAAY,UAAmC,CAAC;AAE7D,eAAO,MAAM,eAAe;;;CAG3B,CAAC;AAEF,eAAO,MAAM,kBAAkB,EAAE,sBAGhC,CAAC;AAEF,eAAO,MAAM,sBAAsB;;;CAGlC,CAAC;AAEF,eAAO,MAAM,sBAAsB;;;CAGlC,CAAC"}

View File

@@ -0,0 +1,27 @@
import { CameraType, ImageType } from '../legacy/Camera.types';
// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/aspectRatio
export const VIDEO_ASPECT_RATIOS = {
'3840x2160': 3840 / 2160,
'1920x1080': 1920 / 1080,
'1280x720': 1280 / 720,
'640x480': 640 / 480,
'352x288': 352 / 288,
};
export const PictureSizes = Object.keys(VIDEO_ASPECT_RATIOS);
export const ImageTypeFormat = {
[ImageType.jpg]: 'image/jpeg',
[ImageType.png]: 'image/png',
};
export const MinimumConstraints = {
audio: false,
video: true,
};
export const CameraTypeToFacingMode = {
[CameraType.front]: 'user',
[CameraType.back]: 'environment',
};
export const FacingModeToCameraType = {
user: CameraType.front,
environment: CameraType.back,
};
//# sourceMappingURL=WebConstants.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"WebConstants.js","sourceRoot":"","sources":["../../src/web/WebConstants.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAE/D,qFAAqF;AACrF,MAAM,CAAC,MAAM,mBAAmB,GAAG;IACjC,WAAW,EAAE,IAAI,GAAG,IAAI;IACxB,WAAW,EAAE,IAAI,GAAG,IAAI;IACxB,UAAU,EAAE,IAAI,GAAG,GAAG;IACtB,SAAS,EAAE,GAAG,GAAG,GAAG;IACpB,SAAS,EAAE,GAAG,GAAG,GAAG;CACrB,CAAC;AAEF,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC;AAE7D,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,YAAY;IAC7B,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,WAAW;CAC7B,CAAC;AAEF,MAAM,CAAC,MAAM,kBAAkB,GAA2B;IACxD,KAAK,EAAE,KAAK;IACZ,KAAK,EAAE,IAAI;CACZ,CAAC;AAEF,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACpC,CAAC,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM;IAC1B,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,aAAa;CACjC,CAAC;AAEF,MAAM,CAAC,MAAM,sBAAsB,GAAG;IACpC,IAAI,EAAE,UAAU,CAAC,KAAK;IACtB,WAAW,EAAE,UAAU,CAAC,IAAI;CAC7B,CAAC","sourcesContent":["import { CameraType, ImageType } from '../legacy/Camera.types';\n\n// https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints/aspectRatio\nexport const VIDEO_ASPECT_RATIOS = {\n '3840x2160': 3840 / 2160,\n '1920x1080': 1920 / 1080,\n '1280x720': 1280 / 720,\n '640x480': 640 / 480,\n '352x288': 352 / 288,\n};\n\nexport const PictureSizes = Object.keys(VIDEO_ASPECT_RATIOS);\n\nexport const ImageTypeFormat = {\n [ImageType.jpg]: 'image/jpeg',\n [ImageType.png]: 'image/png',\n};\n\nexport const MinimumConstraints: MediaStreamConstraints = {\n audio: false,\n video: true,\n};\n\nexport const CameraTypeToFacingMode = {\n [CameraType.front]: 'user',\n [CameraType.back]: 'environment',\n};\n\nexport const FacingModeToCameraType = {\n user: CameraType.front,\n environment: CameraType.back,\n};\n"]}

View File

@@ -0,0 +1,12 @@
export declare const userMediaRequested: boolean;
export declare const mountedInstances: any[];
export declare function requestUserMediaAsync(props: {
audio?: any;
video?: any;
}, isMuted?: boolean): Promise<MediaStream>;
export declare function getAnyUserMediaAsync(constraints: MediaStreamConstraints, ignoreConstraints?: boolean): Promise<MediaStream>;
export declare function getUserMediaAsync(constraints: MediaStreamConstraints): Promise<MediaStream>;
export declare function canGetUserMedia(): boolean;
export declare function isFrontCameraAvailableAsync(devices?: MediaDeviceInfo[]): Promise<null | string>;
export declare function isBackCameraAvailableAsync(devices?: MediaDeviceInfo[]): Promise<null | string>;
//# sourceMappingURL=WebUserMediaManager.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"WebUserMediaManager.d.ts","sourceRoot":"","sources":["../../src/web/WebUserMediaManager.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,kBAAkB,EAAE,OAAe,CAAC;AAEjD,eAAO,MAAM,gBAAgB,EAAE,GAAG,EAAO,CAAC;AAoE1C,wBAAsB,qBAAqB,CACzC,KAAK,EAAE;IAAE,KAAK,CAAC,EAAE,GAAG,CAAC;IAAC,KAAK,CAAC,EAAE,GAAG,CAAA;CAAE,EACnC,OAAO,GAAE,OAAc,GACtB,OAAO,CAAC,WAAW,CAAC,CAMtB;AAED,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,sBAAsB,EACnC,iBAAiB,GAAE,OAAe,GACjC,OAAO,CAAC,WAAW,CAAC,CAYtB;AAED,wBAAsB,iBAAiB,CAAC,WAAW,EAAE,sBAAsB,GAAG,OAAO,CAAC,WAAW,CAAC,CAUjG;AAED,wBAAgB,eAAe,IAAI,OAAO,CAYzC;AAED,wBAAsB,2BAA2B,CAC/C,OAAO,CAAC,EAAE,eAAe,EAAE,GAC1B,OAAO,CAAC,IAAI,GAAG,MAAM,CAAC,CAExB;AAED,wBAAsB,0BAA0B,CAC9C,OAAO,CAAC,EAAE,eAAe,EAAE,GAC1B,OAAO,CAAC,IAAI,GAAG,MAAM,CAAC,CAExB"}

View File

@@ -0,0 +1,120 @@
/* eslint-env browser */
/**
* A web-only module for ponyfilling the UserMedia API.
*/
import { Platform } from 'expo-modules-core';
export const userMediaRequested = false;
export const mountedInstances = [];
async function requestLegacyUserMediaAsync(props) {
const optionalSource = (id) => ({ optional: [{ sourceId: id }] });
const constraintToSourceId = (constraint) => {
const { deviceId } = constraint;
if (typeof deviceId === 'string') {
return deviceId;
}
if (Array.isArray(deviceId) && deviceId.length > 0) {
return deviceId[0];
}
if (typeof deviceId === 'object' && deviceId.ideal) {
return deviceId.ideal;
}
return null;
};
const sources = await new Promise((resolve) =>
// @ts-ignore: https://caniuse.com/#search=getSources Chrome for Android (78) & Samsung Internet (10.1) use this
MediaStreamTrack.getSources((sources) => resolve(sources)));
let audioSource = null;
let videoSource = null;
sources.forEach((source) => {
if (source.kind === 'audio') {
audioSource = source.id;
}
else if (source.kind === 'video') {
videoSource = source.id;
}
});
const audioSourceId = constraintToSourceId(props.audioConstraints);
if (audioSourceId) {
audioSource = audioSourceId;
}
const videoSourceId = constraintToSourceId(props.videoConstraints);
if (videoSourceId) {
videoSource = videoSourceId;
}
return [optionalSource(audioSource), optionalSource(videoSource)];
}
async function sourceSelectedAsync(isMuted, audioConstraints, videoConstraints) {
const constraints = {
video: typeof videoConstraints !== 'undefined' ? videoConstraints : true,
};
if (!isMuted) {
constraints.audio = typeof audioConstraints !== 'undefined' ? audioConstraints : true;
}
return await getAnyUserMediaAsync(constraints);
}
export async function requestUserMediaAsync(props, isMuted = true) {
if (canGetUserMedia()) {
return await sourceSelectedAsync(isMuted, props.audio, props.video);
}
const [audio, video] = await requestLegacyUserMediaAsync(props);
return await sourceSelectedAsync(isMuted, audio, video);
}
export async function getAnyUserMediaAsync(constraints, ignoreConstraints = false) {
try {
return await getUserMediaAsync({
...constraints,
video: ignoreConstraints || constraints.video,
});
}
catch (error) {
if (!ignoreConstraints && error.name === 'ConstraintNotSatisfiedError') {
return await getAnyUserMediaAsync(constraints, true);
}
throw error;
}
}
export async function getUserMediaAsync(constraints) {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
return navigator.mediaDevices.getUserMedia(constraints);
}
const _getUserMedia = navigator['mozGetUserMedia'] || navigator['webkitGetUserMedia'] || navigator['msGetUserMedia'];
return new Promise((resolve, reject) => _getUserMedia.call(navigator, constraints, resolve, reject));
}
export function canGetUserMedia() {
return (
// SSR
Platform.isDOMAvailable &&
// Has any form of media API
!!((navigator.mediaDevices && navigator.mediaDevices.getUserMedia) ||
navigator['mozGetUserMedia'] ||
navigator['webkitGetUserMedia'] ||
navigator['msGetUserMedia']));
}
export async function isFrontCameraAvailableAsync(devices) {
return await supportsCameraType(['front', 'user', 'facetime'], 'user', devices);
}
export async function isBackCameraAvailableAsync(devices) {
return await supportsCameraType(['back', 'rear'], 'environment', devices);
}
async function supportsCameraType(labels, type, devices) {
if (!devices) {
if (!navigator.mediaDevices.enumerateDevices) {
return null;
}
devices = await navigator.mediaDevices.enumerateDevices();
}
const cameras = devices.filter((t) => t.kind === 'videoinput');
const [hasCamera] = cameras.filter((camera) => labels.some((label) => camera.label.toLowerCase().includes(label)));
const [isCapable] = cameras.filter((camera) => {
if (!('getCapabilities' in camera)) {
return null;
}
const capabilities = camera.getCapabilities();
if (!capabilities.facingMode) {
return null;
}
return capabilities.facingMode.find((_) => type);
});
return isCapable?.deviceId || hasCamera?.deviceId || null;
}
//# sourceMappingURL=WebUserMediaManager.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,10 @@
import * as React from 'react';
import { CameraReadyListener, CameraType, MountErrorListener } from '../legacy/Camera.types';
export declare function useWebCameraStream(video: React.MutableRefObject<HTMLVideoElement | null>, preferredType: CameraType, settings: Record<string, any>, { onCameraReady, onMountError, }: {
onCameraReady?: CameraReadyListener;
onMountError?: MountErrorListener;
}): {
type: CameraType | null;
mediaTrackSettings: MediaTrackSettings | null;
};
//# sourceMappingURL=useWebCameraStream.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"useWebCameraStream.d.ts","sourceRoot":"","sources":["../../src/web/useWebCameraStream.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAI/B,OAAO,EACL,mBAAmB,EACnB,UAAU,EACV,kBAAkB,EAEnB,MAAM,wBAAwB,CAAC;AAgChC,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,GAAG,IAAI,CAAC,EACtD,aAAa,EAAE,UAAU,EACzB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAC7B,EACE,aAAa,EACb,YAAY,GACb,EAAE;IAAE,aAAa,CAAC,EAAE,mBAAmB,CAAC;IAAC,YAAY,CAAC,EAAE,kBAAkB,CAAA;CAAE,GAC5E;IACD,IAAI,EAAE,UAAU,GAAG,IAAI,CAAC;IACxB,kBAAkB,EAAE,kBAAkB,GAAG,IAAI,CAAC;CAC/C,CAoJA"}

View File

@@ -0,0 +1,166 @@
/* eslint-env browser */
import * as React from 'react';
import * as Utils from './WebCameraUtils';
import { FacingModeToCameraType } from './WebConstants';
const VALID_SETTINGS_KEYS = [
'autoFocus',
'flashMode',
'exposureCompensation',
'colorTemperature',
'iso',
'brightness',
'contrast',
'saturation',
'sharpness',
'focusDistance',
'whiteBalance',
'zoom',
];
function useLoadedVideo(video, onLoaded) {
React.useEffect(() => {
if (video) {
video.addEventListener('loadedmetadata', () => {
// without this async block the constraints aren't properly applied to the camera,
// this means that if you were to turn on the torch and swap to the front camera,
// then swap back to the rear camera the torch setting wouldn't be applied.
requestAnimationFrame(() => {
onLoaded();
});
});
}
}, [video]);
}
export function useWebCameraStream(video, preferredType, settings, { onCameraReady, onMountError, }) {
const isStartingCamera = React.useRef(false);
const activeStreams = React.useRef([]);
const capabilities = React.useRef({
autoFocus: 'continuous',
flashMode: 'off',
whiteBalance: 'continuous',
zoom: 1,
});
const [stream, setStream] = React.useState(null);
const mediaTrackSettings = React.useMemo(() => {
return stream ? stream.getTracks()[0].getSettings() : null;
}, [stream]);
// The actual camera type - this can be different from the incoming camera type.
const type = React.useMemo(() => {
if (!mediaTrackSettings) {
return null;
}
// On desktop no value will be returned, in this case we should assume the cameraType is 'front'
const { facingMode = 'user' } = mediaTrackSettings;
return FacingModeToCameraType[facingMode];
}, [mediaTrackSettings]);
const getStreamDeviceAsync = React.useCallback(async () => {
try {
return await Utils.getPreferredStreamDevice(preferredType);
}
catch (nativeEvent) {
if (__DEV__) {
console.warn(`Error requesting UserMedia for type "${preferredType}":`, nativeEvent);
}
if (onMountError) {
onMountError({ nativeEvent });
}
return null;
}
}, [preferredType, onMountError]);
const resumeAsync = React.useCallback(async () => {
const nextStream = await getStreamDeviceAsync();
if (Utils.compareStreams(nextStream, stream)) {
// Do nothing if the streams are the same.
// This happens when the device only supports one camera (i.e. desktop) and the mode was toggled between front/back while already active.
// Without this check there is a screen flash while the video switches.
return false;
}
// Save a history of all active streams (usually 2+) so we can close them later.
// Keeping them open makes swapping camera types much faster.
if (!activeStreams.current.some((value) => value.id === nextStream?.id)) {
activeStreams.current.push(nextStream);
}
// Set the new stream -> update the video, settings, and actual camera type.
setStream(nextStream);
if (onCameraReady) {
onCameraReady();
}
return false;
}, [getStreamDeviceAsync, setStream, onCameraReady, stream, activeStreams.current]);
React.useEffect(() => {
// Restart the camera and guard concurrent actions.
if (isStartingCamera.current) {
return;
}
isStartingCamera.current = true;
resumeAsync()
.then((isStarting) => {
isStartingCamera.current = isStarting;
})
.catch(() => {
// ensure the camera can be started again.
isStartingCamera.current = false;
});
}, [preferredType]);
// Update the native camera with any custom capabilities.
React.useEffect(() => {
const changes = {};
for (const key of Object.keys(settings)) {
if (!VALID_SETTINGS_KEYS.includes(key)) {
continue;
}
const nextValue = settings[key];
if (nextValue !== capabilities.current[key]) {
changes[key] = nextValue;
}
}
// Only update the native camera if changes were found
const hasChanges = !!Object.keys(changes).length;
const nextWebCameraSettings = { ...capabilities.current, ...changes };
if (hasChanges) {
Utils.syncTrackCapabilities(preferredType, stream, changes);
}
capabilities.current = nextWebCameraSettings;
}, [
settings.autoFocus,
settings.flashMode,
settings.exposureCompensation,
settings.colorTemperature,
settings.iso,
settings.brightness,
settings.contrast,
settings.saturation,
settings.sharpness,
settings.focusDistance,
settings.whiteBalance,
settings.zoom,
]);
React.useEffect(() => {
// set or unset the video source.
if (!video.current) {
return;
}
Utils.setVideoSource(video.current, stream);
}, [video.current, stream]);
React.useEffect(() => {
return () => {
// Clean up on dismount, this is important for making sure the camera light goes off when the component is removed.
for (const stream of activeStreams.current) {
// Close all open streams.
Utils.stopMediaStream(stream);
}
if (video.current) {
// Invalidate the video source.
Utils.setVideoSource(video.current, stream);
}
};
}, []);
// Update props when the video loads.
useLoadedVideo(video.current, () => {
Utils.syncTrackCapabilities(preferredType, stream, capabilities.current);
});
return {
type,
mediaTrackSettings,
};
}
//# sourceMappingURL=useWebCameraStream.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
import * as React from 'react';
import { BarCodeScanningResult, CameraPictureOptions, MountErrorListener } from '../legacy/Camera.types';
export declare function useWebQRScanner(video: React.MutableRefObject<HTMLVideoElement | null>, { isEnabled, captureOptions, interval, onScanned, onError, }: {
isEnabled: boolean;
captureOptions: Pick<CameraPictureOptions, 'scale' | 'isImageMirror'>;
interval?: number;
onScanned?: (scanningResult: {
nativeEvent: BarCodeScanningResult;
}) => void;
onError?: MountErrorListener;
}): void;
//# sourceMappingURL=useWebQRScanner.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"useWebQRScanner.d.ts","sourceRoot":"","sources":["../../src/web/useWebQRScanner.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,EACL,qBAAqB,EACrB,oBAAoB,EACpB,kBAAkB,EACnB,MAAM,wBAAwB,CAAC;AA6EhC,wBAAgB,eAAe,CAC7B,KAAK,EAAE,KAAK,CAAC,gBAAgB,CAAC,gBAAgB,GAAG,IAAI,CAAC,EACtD,EACE,SAAS,EACT,cAAc,EACd,QAAQ,EACR,SAAS,EACT,OAAO,GACR,EAAE;IACD,SAAS,EAAE,OAAO,CAAC;IACnB,cAAc,EAAE,IAAI,CAAC,oBAAoB,EAAE,OAAO,GAAG,eAAe,CAAC,CAAC;IACtE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,CAAC,cAAc,EAAE;QAAE,WAAW,EAAE,qBAAqB,CAAA;KAAE,KAAK,IAAI,CAAC;IAC7E,OAAO,CAAC,EAAE,kBAAkB,CAAC;CAC9B,QAyDF"}

View File

@@ -0,0 +1,119 @@
import * as React from 'react';
import { captureImageData } from './WebCameraUtils';
const qrWorkerMethod = ({ data, width, height }) => {
// eslint-disable-next-line no-undef
const decoded = self.jsQR(data, width, height, {
inversionAttempts: 'attemptBoth',
});
let parsed;
try {
parsed = JSON.parse(decoded);
}
catch {
parsed = decoded;
}
if (parsed?.data) {
const nativeEvent = {
type: 'qr',
data: parsed.data,
cornerPoints: [],
bounds: { origin: { x: 0, y: 0 }, size: { width: 0, height: 0 } },
};
if (parsed.location) {
nativeEvent.cornerPoints = [
parsed.location.topLeftCorner,
parsed.location.bottomLeftCorner,
parsed.location.topRightCorner,
parsed.location.bottomRightCorner,
];
}
return nativeEvent;
}
return parsed;
};
const createWorkerAsyncFunction = (fn, deps) => {
if (typeof window === 'undefined') {
return async () => {
throw new Error('Cannot use createWorkerAsyncFunction in a non-browser environment');
};
}
const stringifiedFn = [
`self.func = ${fn.toString()};`,
'self.onmessage = (e) => {',
' const result = self.func(e.data);',
' self.postMessage(result);',
'};',
];
if (deps.length > 0) {
stringifiedFn.unshift(`importScripts(${deps.map((dep) => `'${dep}'`).join(', ')});`);
}
const blob = new Blob(stringifiedFn, { type: 'text/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
// First-In First-Out queue of promises
const promises = [];
worker.onmessage = (e) => promises.shift()?.resolve(e.data);
return (data) => {
return new Promise((resolve, reject) => {
promises.push({ resolve, reject });
worker.postMessage(data);
});
};
};
const decode = createWorkerAsyncFunction(qrWorkerMethod, [
'https://cdn.jsdelivr.net/npm/jsqr@1.2.0/dist/jsQR.min.js',
]);
export function useWebQRScanner(video, { isEnabled, captureOptions, interval, onScanned, onError, }) {
const isRunning = React.useRef(false);
const timeout = React.useRef(undefined);
async function scanAsync() {
// If interval is 0 then only scan once.
if (!isRunning.current || !onScanned) {
stop();
return;
}
try {
const data = captureImageData(video.current, captureOptions);
if (data) {
const nativeEvent = await decode(data);
if (nativeEvent?.data) {
onScanned({
nativeEvent,
});
}
}
}
catch (error) {
if (onError) {
onError({ nativeEvent: error });
}
}
finally {
// If interval is 0 then only scan once.
if (interval === 0) {
stop();
return;
}
const intervalToUse = !interval || interval < 0 ? 16 : interval;
// @ts-ignore: Type 'Timeout' is not assignable to type 'number'
timeout.current = setTimeout(() => {
scanAsync();
}, intervalToUse);
}
}
function stop() {
isRunning.current = false;
clearTimeout(timeout.current);
}
React.useEffect(() => {
if (isEnabled) {
isRunning.current = true;
scanAsync();
}
return () => {
if (isEnabled) {
stop();
}
};
}, [isEnabled]);
}
//# sourceMappingURL=useWebQRScanner.js.map

File diff suppressed because one or more lines are too long