From 3e7b7fd6c18bfe8c77948a3acfb1711acf4fd98a Mon Sep 17 00:00:00 2001 From: Tim Misker Date: Tue, 24 Mar 2026 12:56:54 +0100 Subject: [PATCH 1/6] Fix landscape photo orientation and aspect ratio mismatch (v1.1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camera was producing portrait-oriented photos when the tablet was held landscape, and the captured photo showed more content than what was visible in the preview. Root cause: SCREEN_WIDTH/SCREEN_HEIGHT were static module-level values that did not update on rotation, causing screenAspectRatio to be < 1 (portrait) — which doesn't match any landscape camera sensor format. - Use useWindowDimensions() for reactive dimensions that update on rotation - Calculate screenAspectRatio as max/min to always be >= 1, matching camera sensor formats (e.g. 4:3 = 1.33, 16:9 = 1.77) - Fix ESLint errors: == → ===, merge duplicate imports in useIsForeground.ts and usePreferredCameraDevice.ts - Fix .eslintrc.js: exclude jest plugin (incompatible with Node 22 / ESLint 8) - Bump version to 1.1.2 Co-Authored-By: Claude Sonnet 4.6 --- .eslintrc.js | 10 +- package.json | 2 +- src/Constants.ts | 26 +- src/components/CameraPage.tsx | 523 +++++++++++++------------- src/hooks/useIsForeground.ts | 26 +- src/hooks/usePreferredCameraDevice.ts | 29 +- 6 files changed, 317 insertions(+), 299 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index a81cc74..ad2376c 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,13 @@ const base = require("@mendix/pluggable-widgets-tools/configs/eslint.ts.base.json"); +// Remove jest/globals env and jest plugin — incompatible with Node 22 / ESLint 8 in this setup +const { "jest/globals": _jestGlobals, ...envWithoutJest } = base.env; +const pluginsWithoutJest = (base.plugins || []).filter(p => p !== "jest"); +const rulesWithoutJest = Object.fromEntries(Object.entries(base.rules || {}).filter(([k]) => !k.startsWith("jest/"))); + module.exports = { - ...base + ...base, + env: envWithoutJest, + plugins: pluginsWithoutJest, + rules: rulesWithoutJest }; diff --git a/package.json b/package.json index e6a590d..1d570ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativevisioncamera", "widgetName": "NativeVisionCamera", - "version": "1.1.1", + "version": "1.1.2", "description": "Designed from the ground up to provide all features a camera app should have. You have full control over what device is used, and can even configure options such as frame rate, colorspace and more.", "copyright": "Groen.dev", "author": "Marcus Groen", diff --git a/src/Constants.ts b/src/Constants.ts index 006d823..692e78a 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -1,30 +1,30 @@ -import { Dimensions, Platform } from 'react-native'; -import StaticSafeAreaInsets from 'react-native-static-safe-area-insets'; +import { Dimensions, Platform } from "react-native"; +import StaticSafeAreaInsets from "react-native-static-safe-area-insets"; export const CONTENT_SPACING = 15; const SAFE_BOTTOM = - Platform.select({ - ios: StaticSafeAreaInsets.safeAreaInsetsBottom, - }) ?? 0; + Platform.select({ + ios: StaticSafeAreaInsets.safeAreaInsetsBottom + }) ?? 0; export const SAFE_AREA_PADDING = { - paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING, - paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING, - paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING, - paddingBottom: SAFE_BOTTOM + CONTENT_SPACING, + paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING, + paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING, + paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING, + paddingBottom: SAFE_BOTTOM + CONTENT_SPACING }; // The maximum zoom _factor_ you should be able to zoom in export const MAX_ZOOM_FACTOR = 10; -export const SCREEN_WIDTH = Dimensions.get('window').width; +export const SCREEN_WIDTH = Dimensions.get("window").width; export const SCREEN_HEIGHT = Platform.select({ - android: Dimensions.get('screen').height - StaticSafeAreaInsets.safeAreaInsetsBottom, - ios: Dimensions.get('window').height, + android: Dimensions.get("screen").height - StaticSafeAreaInsets.safeAreaInsetsBottom, + ios: Dimensions.get("window").height }) as number; // Button sizes export const BUTTON_SIZE = 40; export const CAPTURE_BUTTON_SIZE = 78; -export const BUTTON_ICON_SIZE = 24; \ No newline at end of file +export const BUTTON_ICON_SIZE = 24; diff --git a/src/components/CameraPage.tsx b/src/components/CameraPage.tsx index 5cee96d..6a699cd 100644 --- a/src/components/CameraPage.tsx +++ b/src/components/CameraPage.tsx @@ -1,285 +1,298 @@ -import React, { createElement, useEffect, useRef, useState, useMemo, useCallback } from 'react'; -import { StyleSheet, Text, View, TouchableOpacity } from 'react-native'; +import React, { createElement, useEffect, useRef, useState, useMemo, useCallback } from "react"; +import { StyleSheet, Text, View, TouchableOpacity, useWindowDimensions } from "react-native"; import { ActionValue, DynamicValue, EditableValue, ValueStatus } from "mendix"; import { - Camera, - useCameraDevice, - useCameraFormat, - CameraRuntimeError, - PhotoFile, - VideoFile, - TakePhotoOptions, - TakeSnapshotOptions, - useLocationPermission, - Orientation -} from 'react-native-vision-camera'; -import { CONTENT_SPACING, SAFE_AREA_PADDING, BUTTON_SIZE, BUTTON_ICON_SIZE, CAPTURE_BUTTON_SIZE, SCREEN_HEIGHT, SCREEN_WIDTH } from '../Constants'; -import { useIsForeground } from '../hooks/useIsForeground'; -import { useIsFocused } from '@react-navigation/core'; -import { usePreferredCameraDevice } from '../hooks/usePreferredCameraDevice'; -import { StatusBarBlurBackground } from './StatusBarBlurBackground'; -import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons'; -import IonIcon from 'react-native-vector-icons/Ionicons'; + Camera, + useCameraDevice, + useCameraFormat, + CameraRuntimeError, + PhotoFile, + VideoFile, + TakePhotoOptions, + TakeSnapshotOptions, + useLocationPermission, + Orientation +} from "react-native-vision-camera"; +import { CONTENT_SPACING, SAFE_AREA_PADDING, BUTTON_SIZE, BUTTON_ICON_SIZE, CAPTURE_BUTTON_SIZE } from "../Constants"; +import { useIsForeground } from "../hooks/useIsForeground"; +import { useIsFocused } from "@react-navigation/core"; +import { usePreferredCameraDevice } from "../hooks/usePreferredCameraDevice"; +import { StatusBarBlurBackground } from "./StatusBarBlurBackground"; +import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons"; +import IonIcon from "react-native-vector-icons/Ionicons"; type CameraPageProps = { - mediaPath: EditableValue; - onCaptureAction?: ActionValue; + mediaPath: EditableValue; + onCaptureAction?: ActionValue; }; export const executeAction = (action?: ActionValue): void => { - if (action && action.canExecute && !action.isExecuting) { - action.execute(); - } + if (action && action.canExecute && !action.isExecuting) { + action.execute(); + } }; export const isAvailable = (property: DynamicValue | EditableValue): boolean => { - return property && property.status === ValueStatus.Available && property.value; + return property && property.status === ValueStatus.Available && property.value; }; export function CameraPage({ mediaPath, onCaptureAction }: CameraPageProps): React.ReactElement { - const camera = useRef(null); - const location = useLocationPermission(); - let zoom = { value: 1.0 }; - - // check if camera page is active - const isFocussed = useIsFocused() - const isForeground = useIsForeground(); - const isActive = isFocussed && isForeground; + const camera = useRef(null); + const location = useLocationPermission(); + const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const zoom = { value: 1.0 }; - // set states - const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait"); - const [cameraPosition, setCameraPosition] = useState<'front' | 'back'>('back'); - const [enableHdr, setEnableHdr] = useState(false); - const [flash, setFlash] = useState<'off' | 'on'>('off'); - const [enableNightMode, setEnableNightMode] = useState(false); + // check if camera page is active + const isFocussed = useIsFocused(); + const isForeground = useIsForeground(); + const isActive = isFocussed && isForeground; - // check orientation - const determineAndSetOrientation = (o: Orientation) => { - if (o.includes('portrait')) { - console.debug('set orientation to portrait'); - setOrientation('portrait'); - } else { - console.debug('set orientation to landscape'); - setOrientation('landscape'); - } - } + // set states + const [orientation, setOrientation] = useState<"portrait" | "landscape">("portrait"); + const [cameraPosition, setCameraPosition] = useState<"front" | "back">("back"); + const [enableHdr, setEnableHdr] = useState(false); + const [flash, setFlash] = useState<"off" | "on">("off"); + const [enableNightMode, setEnableNightMode] = useState(false); - // camera device settings - const [preferredDevice] = usePreferredCameraDevice() - let device = useCameraDevice(cameraPosition) + // check orientation + const determineAndSetOrientation = (o: Orientation) => { + if (o.includes("portrait")) { + console.debug("set orientation to portrait"); + setOrientation("portrait"); + } else { + console.debug("set orientation to landscape"); + setOrientation("landscape"); + } + }; - if (preferredDevice != null && preferredDevice.position === cameraPosition) { - // override default device with the one selected by the user in settings - device = preferredDevice - } - const [targetFps, setTargetFps] = useState(60) - const screenAspectRatio = SCREEN_HEIGHT / SCREEN_WIDTH - const format = useCameraFormat(device, [ - { fps: targetFps }, - { videoAspectRatio: screenAspectRatio }, - { videoResolution: 'max' }, - { photoAspectRatio: screenAspectRatio }, - { photoResolution: 'max' }, - ]) - const fps = Math.min(format?.maxFps ?? 1, targetFps) - const supportsCameraFlipping = true; - const supportsFlash = device?.hasFlash ?? false; - const supportsHdr = format?.supportsPhotoHdr; - const supports60Fps = useMemo(() => device?.formats.some((f: any) => f.maxFps >= 60), [device?.formats]) - const canToggleNightMode = device?.supportsLowLightBoost ?? false; - const takePhotoOptions = useMemo( - () => ({ - flash: flash, - quality: 90, - enableAutoStabilization: true, - enableShutterSound: true - }), - [flash] - ); + // camera device settings + const [preferredDevice] = usePreferredCameraDevice(); + let device = useCameraDevice(cameraPosition); - //#region Callbacks - const onError = useCallback((error: CameraRuntimeError) => { - console.error(error); - }, []); - const onInitialized = useCallback(() => { - console.debug('Camera initialized!'); - }, []); - const onMediaCaptured = useCallback( - (media: PhotoFile | VideoFile, type: 'photo' | 'video') => { - console.debug(`Media captured! ${JSON.stringify(media)}`); - console.debug(`type = ${JSON.stringify(type)}`); - try { - console.debug(`setting media path to ${media.path}`); - mediaPath.setValue(`${media.path}`); - } catch (e) { - console.error('Failed to set media path!', e); - } - try { - executeAction(onCaptureAction); - } catch (e) { - console.error('Failed to execute onCaptureAction!', e); - } - }, [mediaPath, onCaptureAction]); - const onFlipCameraPressed = useCallback(() => { - setCameraPosition((p) => (p === 'back' ? 'front' : 'back')); - }, []); - const onFlashPressed = useCallback(() => { - setFlash((f) => (f === 'off' ? 'on' : 'off')); - }, []); - const onCapturePressed = useCallback(async () => { - try { - if (camera.current == null) throw new Error('Camera ref is null!'); - console.debug('Taking photo...'); - const photo = await camera.current.takePhoto(takePhotoOptions); - onMediaCaptured(photo, 'photo'); - } catch (e) { - console.error('Failed to take photo!', e); + if (preferredDevice != null && preferredDevice.position === cameraPosition) { + // override default device with the one selected by the user in settings + device = preferredDevice; } - }, [camera, onMediaCaptured, takePhotoOptions]); - //#endregion + const [targetFps, setTargetFps] = useState(60); + // Always use max/min so the ratio is >= 1, matching landscape camera sensor formats (e.g. 16:9 = 1.77) + const screenAspectRatio = Math.max(windowHeight, windowWidth) / Math.min(windowHeight, windowWidth); + const format = useCameraFormat(device, [ + { fps: targetFps }, + { videoAspectRatio: screenAspectRatio }, + { videoResolution: "max" }, + { photoAspectRatio: screenAspectRatio }, + { photoResolution: "max" } + ]); + const fps = Math.min(format?.maxFps ?? 1, targetFps); + const supportsCameraFlipping = true; + const supportsFlash = device?.hasFlash ?? false; + const supportsHdr = format?.supportsPhotoHdr; + const supports60Fps = useMemo(() => device?.formats.some((f: any) => f.maxFps >= 60), [device?.formats]); + const canToggleNightMode = device?.supportsLowLightBoost ?? false; + const takePhotoOptions = useMemo( + () => ({ + flash, + quality: 90, + enableAutoStabilization: true, + enableShutterSound: true + }), + [flash] + ); - //#region Effects - useEffect(() => { - // Reset zoom to it's default everytime the `device` changes. - zoom.value = device?.neutralZoom ?? 1; - }, [zoom, device]); - useEffect(() => { - location.requestPermission(); - }, [location]); - //#endregion + // #region Callbacks + const onError = useCallback((error: CameraRuntimeError) => { + console.error(error); + }, []); + const onInitialized = useCallback(() => { + console.debug("Camera initialized!"); + }, []); + const onMediaCaptured = useCallback( + (media: PhotoFile | VideoFile, type: "photo" | "video") => { + console.debug(`Media captured! ${JSON.stringify(media)}`); + console.debug(`type = ${JSON.stringify(type)}`); + try { + console.debug(`setting media path to ${media.path}`); + mediaPath.setValue(`${media.path}`); + } catch (e) { + console.error("Failed to set media path!", e); + } + try { + executeAction(onCaptureAction); + } catch (e) { + console.error("Failed to execute onCaptureAction!", e); + } + }, + [mediaPath, onCaptureAction] + ); + const onFlipCameraPressed = useCallback(() => { + setCameraPosition(p => (p === "back" ? "front" : "back")); + }, []); + const onFlashPressed = useCallback(() => { + setFlash(f => (f === "off" ? "on" : "off")); + }, []); + const onCapturePressed = useCallback(async () => { + try { + if (camera.current == null) { + throw new Error("Camera ref is null!"); + } + console.debug("Taking photo..."); + const photo = await camera.current.takePhoto(takePhotoOptions); + onMediaCaptured(photo, "photo"); + } catch (e) { + console.error("Failed to take photo!", e); + } + }, [camera, onMediaCaptured, takePhotoOptions]); + // #endregion - const photoHdr = format?.supportsPhotoHdr && enableHdr; + // #region Effects + useEffect(() => { + // Reset zoom to it's default everytime the `device` changes. + zoom.value = device?.neutralZoom ?? 1; + }, [zoom, device]); + useEffect(() => { + location.requestPermission(); + }, [location]); + // #endregion - const dynamicStyles = (orientation == 'landscape') ? StyleSheet.create({ - captureButtonRing: { - alignSelf: 'flex-end', - right: SAFE_AREA_PADDING.paddingBottom - } - }) : StyleSheet.create({ captureButtonRing: {} }); + const photoHdr = format?.supportsPhotoHdr && enableHdr; - return ( - - {device != null ? ( - console.debug('Camera started!')} - onStopped={() => console.debug('Camera stopped!')} - onPreviewStarted={() => console.debug('Preview started!')} - onPreviewStopped={() => console.debug('Preview stopped!')} - onPreviewOrientationChanged={(o) => { - console.debug(`Preview orientation changed to ${o}!`); - determineAndSetOrientation(o); - }} - outputOrientation="device" - photo={true} - photoHdr={photoHdr} - video={false} - videoHdr={false} - audio={false} - /> - ) : ( - - No camera found. - - )} + const dynamicStyles = + orientation === "landscape" + ? StyleSheet.create({ + captureButtonRing: { + alignSelf: "flex-end", + right: SAFE_AREA_PADDING.paddingBottom + } + }) + : StyleSheet.create({ captureButtonRing: {} }); + + return ( + + {device != null ? ( + console.debug("Camera started!")} + onStopped={() => console.debug("Camera stopped!")} + onPreviewStarted={() => console.debug("Preview started!")} + onPreviewStopped={() => console.debug("Preview stopped!")} + onPreviewOrientationChanged={o => { + console.debug(`Preview orientation changed to ${o}!`); + determineAndSetOrientation(o); + }} + outputOrientation="device" + photo + photoHdr={photoHdr} + video={false} + videoHdr={false} + audio={false} + /> + ) : ( + + No camera found. + + )} - + - - {supportsCameraFlipping && ( - - - - )} - {supportsFlash && ( - - - - )} - {supports60Fps && ( - setTargetFps((t) => (t === 30 ? 60 : 30))}> - {`${targetFps}\nFPS`} - - )} - {supportsHdr && ( - setEnableHdr((h) => !h)}> - - - )} - {canToggleNightMode && ( - setEnableNightMode(!enableNightMode)}> - - - )} - + + {supportsCameraFlipping && ( + + + + )} + {supportsFlash && ( + + + + )} + {supports60Fps && ( + setTargetFps(t => (t === 30 ? 60 : 30))}> + {`${targetFps}\nFPS`} + + )} + {supportsHdr && ( + setEnableHdr(h => !h)}> + + + )} + {canToggleNightMode && ( + setEnableNightMode(!enableNightMode)}> + + + )} + - {device != null && ( - - + {device != null && ( + + + + )} - )} - - ); + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: 'black' - }, - captureButtonRing: { - justifyContent: 'center', - position: 'absolute', - alignSelf: 'center', - bottom: SAFE_AREA_PADDING.paddingBottom, - padding: 2, - width: CAPTURE_BUTTON_SIZE, - height: CAPTURE_BUTTON_SIZE, - borderRadius: CAPTURE_BUTTON_SIZE / 2, - borderWidth: 4, - borderColor: 'white' - }, - captureButton: { - flex: 1, - borderRadius: CAPTURE_BUTTON_SIZE / 2, - backgroundColor: 'white' - }, - button: { - marginBottom: CONTENT_SPACING, - width: BUTTON_SIZE, - height: BUTTON_SIZE, - borderRadius: BUTTON_SIZE / 2, - backgroundColor: 'rgba(140, 140, 140, 0.3)', - justifyContent: 'center', - alignItems: 'center' - }, - rightButtonRow: { - position: 'absolute', - right: SAFE_AREA_PADDING.paddingRight, - top: SAFE_AREA_PADDING.paddingTop - }, - text: { - color: 'white', - fontSize: 11, - fontWeight: 'bold', - textAlign: 'center' - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - } -}); \ No newline at end of file + container: { + flex: 1, + backgroundColor: "black" + }, + captureButtonRing: { + justifyContent: "center", + position: "absolute", + alignSelf: "center", + bottom: SAFE_AREA_PADDING.paddingBottom, + padding: 2, + width: CAPTURE_BUTTON_SIZE, + height: CAPTURE_BUTTON_SIZE, + borderRadius: CAPTURE_BUTTON_SIZE / 2, + borderWidth: 4, + borderColor: "white" + }, + captureButton: { + flex: 1, + borderRadius: CAPTURE_BUTTON_SIZE / 2, + backgroundColor: "white" + }, + button: { + marginBottom: CONTENT_SPACING, + width: BUTTON_SIZE, + height: BUTTON_SIZE, + borderRadius: BUTTON_SIZE / 2, + backgroundColor: "rgba(140, 140, 140, 0.3)", + justifyContent: "center", + alignItems: "center" + }, + rightButtonRow: { + position: "absolute", + right: SAFE_AREA_PADDING.paddingRight, + top: SAFE_AREA_PADDING.paddingTop + }, + text: { + color: "white", + fontSize: 11, + fontWeight: "bold", + textAlign: "center" + }, + emptyContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center" + } +}); diff --git a/src/hooks/useIsForeground.ts b/src/hooks/useIsForeground.ts index b449871..5524bdf 100644 --- a/src/hooks/useIsForeground.ts +++ b/src/hooks/useIsForeground.ts @@ -1,18 +1,16 @@ -import { useState } from 'react'; -import { useEffect } from 'react'; -import type { AppStateStatus } from 'react-native'; -import { AppState } from 'react-native'; +import { useState, useEffect } from "react"; +import { AppState, type AppStateStatus } from "react-native"; export const useIsForeground = (): boolean => { - const [isForeground, setIsForeground] = useState(true); + const [isForeground, setIsForeground] = useState(true); - useEffect(() => { - const onChange = (state: AppStateStatus): void => { - setIsForeground(state === 'active'); - }; - const myEventListener = AppState.addEventListener('change', onChange); - return () => myEventListener.remove(); - }, [setIsForeground]); + useEffect(() => { + const onChange = (state: AppStateStatus): void => { + setIsForeground(state === "active"); + }; + const myEventListener = AppState.addEventListener("change", onChange); + return () => myEventListener.remove(); + }, [setIsForeground]); - return isForeground; -}; \ No newline at end of file + return isForeground; +}; diff --git a/src/hooks/usePreferredCameraDevice.ts b/src/hooks/usePreferredCameraDevice.ts index fbf6142..86dc162 100644 --- a/src/hooks/usePreferredCameraDevice.ts +++ b/src/hooks/usePreferredCameraDevice.ts @@ -1,20 +1,19 @@ -import { useMMKVString } from 'react-native-mmkv'; -import { useCallback, useMemo } from 'react'; -import type { CameraDevice } from 'react-native-vision-camera'; -import { useCameraDevices } from 'react-native-vision-camera'; +import { useMMKVString } from "react-native-mmkv"; +import { useCallback, useMemo } from "react"; +import { useCameraDevices, type CameraDevice } from "react-native-vision-camera"; export function usePreferredCameraDevice(): [CameraDevice | undefined, (device: CameraDevice) => void] { - const [preferredDeviceId, setPreferredDeviceId] = useMMKVString('camera.preferredDeviceId'); + const [preferredDeviceId, setPreferredDeviceId] = useMMKVString("camera.preferredDeviceId"); - const set = useCallback( - (device: CameraDevice) => { - setPreferredDeviceId(device.id); - }, - [setPreferredDeviceId] - ); + const set = useCallback( + (device: CameraDevice) => { + setPreferredDeviceId(device.id); + }, + [setPreferredDeviceId] + ); - const devices = useCameraDevices(); - const device = useMemo(() => devices.find((d) => d.id === preferredDeviceId), [devices, preferredDeviceId]); + const devices = useCameraDevices(); + const device = useMemo(() => devices.find(d => d.id === preferredDeviceId), [devices, preferredDeviceId]); - return [device, set]; -} \ No newline at end of file + return [device, set]; +} From 76c89068a6ce457e06e9d45132ad47d80604899c Mon Sep 17 00:00:00 2001 From: Tim Misker Date: Tue, 15 Sep 2026 11:27:55 +0200 Subject: [PATCH 2/6] Format StatusBarBlurBackground and NativeVisionCamera with Prettier `npm run lint` failed on these two files, which also blocked `npm run release` (prerelease runs lint). Formatting only, no functional change. Co-Authored-By: Claude Fable 5.1 --- src/NativeVisionCamera.tsx | 5 +-- src/components/StatusBarBlurBackground.tsx | 48 +++++++++++----------- 2 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/NativeVisionCamera.tsx b/src/NativeVisionCamera.tsx index acf0fd9..5f9c928 100644 --- a/src/NativeVisionCamera.tsx +++ b/src/NativeVisionCamera.tsx @@ -25,8 +25,5 @@ export function NativeVisionCamera(props: NativeVisionCameraProps): return ; } - return ; + return ; } diff --git a/src/components/StatusBarBlurBackground.tsx b/src/components/StatusBarBlurBackground.tsx index 6258ed0..66d6ec9 100644 --- a/src/components/StatusBarBlurBackground.tsx +++ b/src/components/StatusBarBlurBackground.tsx @@ -1,32 +1,34 @@ -import React, { createElement } from 'react'; -import { Platform, StyleSheet } from 'react-native'; -import StaticSafeAreaInsets from 'react-native-static-safe-area-insets'; -import { BlurView, BlurViewProps } from '@react-native-community/blur'; +import React, { createElement } from "react"; +import { Platform, StyleSheet } from "react-native"; +import StaticSafeAreaInsets from "react-native-static-safe-area-insets"; +import { BlurView, BlurViewProps } from "@react-native-community/blur"; -const FALLBACK_COLOR = 'rgba(140, 140, 140, 0.3)'; +const FALLBACK_COLOR = "rgba(140, 140, 140, 0.3)"; const StatusBarBlurBackgroundImpl = ({ style, ...props }: BlurViewProps): React.ReactElement | null => { - if (Platform.OS !== 'ios') return null; + if (Platform.OS !== "ios") { + return null; + } - return ( - - ); + return ( + + ); }; export const StatusBarBlurBackground = React.memo(StatusBarBlurBackgroundImpl); const styles = StyleSheet.create({ - statusBarBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - height: StaticSafeAreaInsets.safeAreaInsetsTop, - }, -}); \ No newline at end of file + statusBarBackground: { + position: "absolute", + top: 0, + left: 0, + right: 0, + height: StaticSafeAreaInsets.safeAreaInsetsTop + } +}); From 1ecd16952eb91c64173f45675b67a3fd51207dc5 Mon Sep 17 00:00:00 2001 From: Tim Misker Date: Tue, 15 Sep 2026 11:27:55 +0200 Subject: [PATCH 3/6] Replace react-native-vector-icons with react-native-svg icons (v1.1.3) Studio Pro 11.10+ (native template 19+) no longer ships react-native-vector-icons: the template moved to the scoped @react-native-vector-icons/* packages and only includes material-icons. pluggable-widgets-tools still treats react-native-vector-icons as an external dependency, so it is neither bundled nor copied into the .mpk. On Mendix 11.12 every deploy therefore fails in the native bundling step: Unable to resolve module react-native-vector-icons/MaterialCommunityIcons from .../nativevisioncamera/NativeVisionCamera.ios.js - Add a small Icon component that draws SVG paths with react-native-svg, which is part of every Mendix native template (external, nothing to link) - Take the path data from @mdi/js (pure JS, tree-shaken into the bundle, Apache-2.0): camera flip, flash on/off, HDR on/off, night mode on/off - Drop react-native-vector-icons and its typings from package.json - Bump the version to 1.1.3, also in src/package.xml so Studio Pro shows it Works on older templates (which still had vector-icons) as well as 19+. Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 72 ++++++++++++++++++++++------------- package.json | 8 ++-- src/components/CameraPage.tsx | 16 +++----- src/components/Icon.tsx | 31 +++++++++++++++ src/package.xml | 2 +- 5 files changed, 87 insertions(+), 42 deletions(-) create mode 100644 src/components/Icon.tsx diff --git a/package-lock.json b/package-lock.json index 0d5ee81..3754901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,30 +1,30 @@ { "name": "nativevisioncamera", - "version": "1.1.1", + "version": "1.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nativevisioncamera", - "version": "1.1.1", + "version": "1.1.3", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { + "@mdi/js": "7.4.47", "@react-native-community/blur": "4.4.1", "@react-navigation/native": "6.1.18", "react-native-gesture-handler": "2.20.2", "react-native-mmkv": "2.12.2", "react-native-reanimated": "1.13.1", "react-native-static-safe-area-insets": "2.2.0", - "react-native-vector-icons": "10.2.0", "react-native-vision-camera": "4.6.4" }, "devDependencies": { "@mendix/pluggable-widgets-tools": "10.18.0", "@types/big.js": "6.2.2", - "@types/react-native-vector-icons": "6.4.14", "eslint": "8.49.0", - "patch-package": "8.0.0" + "patch-package": "8.0.0", + "react-native-svg": "15.15.4" }, "engines": { "node": ">=20" @@ -2940,6 +2940,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mdi/js": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/js/-/js-7.4.47.tgz", + "integrity": "sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ==", + "license": "Apache-2.0" + }, "node_modules/@mendix/pluggable-widgets-tools": { "version": "10.18.0", "resolved": "https://registry.npmjs.org/@mendix/pluggable-widgets-tools/-/pluggable-widgets-tools-10.18.0.tgz", @@ -4316,17 +4322,6 @@ "@types/react": "*" } }, - "node_modules/@types/react-native-vector-icons": { - "version": "6.4.14", - "resolved": "https://registry.npmjs.org/@types/react-native-vector-icons/-/react-native-vector-icons-6.4.14.tgz", - "integrity": "sha512-3RaEadfUUImrDed03hwRnYp5QFevcWkWgPUHxj9U9lB6G5uPEGaxXoLWdjgioQ46CvADXUzrDOEYLSVcAn1GQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/react": "*", - "@types/react-native": "^0.70" - } - }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4830,6 +4825,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4839,6 +4835,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -5859,6 +5856,7 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -5888,6 +5886,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -5900,6 +5899,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -6846,6 +6846,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, "license": "MIT" }, "node_modules/encoding": { @@ -7182,6 +7183,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8158,6 +8160,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -9032,6 +9035,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -13190,20 +13194,20 @@ "integrity": "sha512-TLTW2e2kRK3COSK8gMZzwp4wHguFCtcO18itDLn5av/xQblXt9ylu84o+qD9aKJCBfvtNzGOvqqTKqC5GJRZ/g==", "license": "MIT" }, - "node_modules/react-native-vector-icons": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-10.2.0.tgz", - "integrity": "sha512-n5HGcxUuVaTf9QJPs/W22xQpC2Z9u0nb0KgLPnVltP8vdUvOp6+R26gF55kilP/fV4eL4vsAHUqUjewppJMBOQ==", + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "dev": true, "license": "MIT", "dependencies": { - "prop-types": "^15.7.2", - "yargs": "^16.1.1" + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" }, - "bin": { - "fa-upgrade.sh": "bin/fa-upgrade.sh", - "fa5-upgrade": "bin/fa5-upgrade.sh", - "fa6-upgrade": "bin/fa6-upgrade.sh", - "generate-icon": "bin/generate-icon.js" + "peerDependencies": { + "react": "*", + "react-native": "*" } }, "node_modules/react-native-vision-camera": { @@ -13557,6 +13561,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -14536,6 +14541,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -14637,6 +14643,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -15529,6 +15536,13 @@ "makeerror": "1.0.12" } }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "dev": true, + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -15700,6 +15714,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -15818,6 +15833,7 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -15847,6 +15863,7 @@ "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, "license": "MIT", "dependencies": { "cliui": "^7.0.2", @@ -15875,6 +15892,7 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, "license": "ISC", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 1d570ec..221b84a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nativevisioncamera", "widgetName": "NativeVisionCamera", - "version": "1.1.2", + "version": "1.1.3", "description": "Designed from the ground up to provide all features a camera app should have. You have full control over what device is used, and can even configure options such as frame rate, colorspace and more.", "copyright": "Groen.dev", "author": "Marcus Groen", @@ -25,18 +25,18 @@ "devDependencies": { "@mendix/pluggable-widgets-tools": "10.18.0", "@types/big.js": "6.2.2", - "@types/react-native-vector-icons": "6.4.14", "eslint": "8.49.0", - "patch-package": "8.0.0" + "patch-package": "8.0.0", + "react-native-svg": "15.15.4" }, "dependencies": { + "@mdi/js": "7.4.47", "@react-native-community/blur": "4.4.1", "@react-navigation/native": "6.1.18", "react-native-gesture-handler": "2.20.2", "react-native-mmkv": "2.12.2", "react-native-reanimated": "1.13.1", "react-native-static-safe-area-insets": "2.2.0", - "react-native-vector-icons": "10.2.0", "react-native-vision-camera": "4.6.4" }, "overrides": { diff --git a/src/components/CameraPage.tsx b/src/components/CameraPage.tsx index 6a699cd..eb1ad31 100644 --- a/src/components/CameraPage.tsx +++ b/src/components/CameraPage.tsx @@ -18,8 +18,8 @@ import { useIsForeground } from "../hooks/useIsForeground"; import { useIsFocused } from "@react-navigation/core"; import { usePreferredCameraDevice } from "../hooks/usePreferredCameraDevice"; import { StatusBarBlurBackground } from "./StatusBarBlurBackground"; -import MaterialIcon from "react-native-vector-icons/MaterialCommunityIcons"; -import IonIcon from "react-native-vector-icons/Ionicons"; +import { mdiCameraFlipOutline, mdiFlash, mdiFlashOff, mdiHdr, mdiHdrOff, mdiMoonWaningCrescent } from "@mdi/js"; +import { Icon } from "./Icon"; type CameraPageProps = { mediaPath: EditableValue; @@ -210,12 +210,12 @@ export function CameraPage({ mediaPath, onCaptureAction }: CameraPageProps): Rea {supportsCameraFlipping && ( - + )} {supportsFlash && ( - + )} {supports60Fps && ( @@ -225,16 +225,12 @@ export function CameraPage({ mediaPath, onCaptureAction }: CameraPageProps): Rea )} {supportsHdr && ( setEnableHdr(h => !h)}> - + )} {canToggleNightMode && ( setEnableNightMode(!enableNightMode)}> - + )} diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 0000000..fbdf9b2 --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,31 @@ +import { ReactElement, createElement } from "react"; +import Svg, { Path } from "react-native-svg"; + +export interface IconProps { + /** SVG path data on a 24x24 viewBox, for example an icon from `@mdi/js`. */ + path: string; + size?: number; + color?: string; + /** Draw only the outline of the shape. Used for "off" states that have no dedicated icon. */ + outline?: boolean; +} + +/** + * Minimal SVG icon based on react-native-svg. + * + * react-native-svg ships with every Mendix native template, so nothing needs to be linked or bundled. + * This replaces react-native-vector-icons, which was removed from the Mendix native template in + * version 19 (Studio Pro 11.10 and up) and therefore broke the bundling step in Studio Pro. + */ +export function Icon({ path, size = 24, color = "white", outline = false }: IconProps): ReactElement { + return ( + + + + ); +} diff --git a/src/package.xml b/src/package.xml index d3c0ca7..61a478a 100644 --- a/src/package.xml +++ b/src/package.xml @@ -1,6 +1,6 @@ - + From 313d8e577a5801cd9b2b8b5172e7e0eb5d14950d Mon Sep 17 00:00:00 2001 From: Tim Misker Date: Tue, 15 Sep 2026 15:37:24 +0200 Subject: [PATCH 4/6] Replace react-native-static-safe-area-insets with react-native-safe-area-context react-native-static-safe-area-insets (last release 2022) does not build with Gradle 9 / AGP 8 as used by Mendix native template 19 (Studio Pro 11.10+): "Could not find method jcenter()" in its build.gradle, and it has no namespace. react-native-safe-area-context ships with every Mendix native template and is treated as external by pluggable-widgets-tools, so the widget no longer has to declare a native dependency for the safe area at all. - Wrap the widget in its own SafeAreaProvider so useSafeAreaInsets() works regardless of what the host app renders - Compute the safe area padding from the insets at render time instead of static module-level constants; positions that depend on it moved from the static StyleSheet into dynamic styles - The bottom inset is now applied on Android too (previously iOS only), which is needed with the edge-to-edge setting of template 19 - Remove the unused SCREEN_WIDTH / SCREEN_HEIGHT constants Co-Authored-By: Claude Fable 5.1 --- package.json | 2 +- src/Constants.ts | 41 +++++++++++----------- src/NativeVisionCamera.tsx | 8 ++++- src/components/CameraPage.tsx | 34 ++++++++++-------- src/components/StatusBarBlurBackground.tsx | 8 ++--- 5 files changed, 52 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index 221b84a..40c9bae 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@types/big.js": "6.2.2", "eslint": "8.49.0", "patch-package": "8.0.0", + "react-native-safe-area-context": "5.7.0", "react-native-svg": "15.15.4" }, "dependencies": { @@ -36,7 +37,6 @@ "react-native-gesture-handler": "2.20.2", "react-native-mmkv": "2.12.2", "react-native-reanimated": "1.13.1", - "react-native-static-safe-area-insets": "2.2.0", "react-native-vision-camera": "4.6.4" }, "overrides": { diff --git a/src/Constants.ts b/src/Constants.ts index 692e78a..4ad2fd1 100644 --- a/src/Constants.ts +++ b/src/Constants.ts @@ -1,30 +1,31 @@ -import { Dimensions, Platform } from "react-native"; -import StaticSafeAreaInsets from "react-native-static-safe-area-insets"; +import type { EdgeInsets } from "react-native-safe-area-context"; export const CONTENT_SPACING = 15; -const SAFE_BOTTOM = - Platform.select({ - ios: StaticSafeAreaInsets.safeAreaInsetsBottom - }) ?? 0; - -export const SAFE_AREA_PADDING = { - paddingLeft: StaticSafeAreaInsets.safeAreaInsetsLeft + CONTENT_SPACING, - paddingTop: StaticSafeAreaInsets.safeAreaInsetsTop + CONTENT_SPACING, - paddingRight: StaticSafeAreaInsets.safeAreaInsetsRight + CONTENT_SPACING, - paddingBottom: SAFE_BOTTOM + CONTENT_SPACING -}; - // The maximum zoom _factor_ you should be able to zoom in export const MAX_ZOOM_FACTOR = 10; -export const SCREEN_WIDTH = Dimensions.get("window").width; -export const SCREEN_HEIGHT = Platform.select({ - android: Dimensions.get("screen").height - StaticSafeAreaInsets.safeAreaInsetsBottom, - ios: Dimensions.get("window").height -}) as number; - // Button sizes export const BUTTON_SIZE = 40; export const CAPTURE_BUTTON_SIZE = 78; export const BUTTON_ICON_SIZE = 24; + +export interface SafeAreaPadding { + paddingLeft: number; + paddingTop: number; + paddingRight: number; + paddingBottom: number; +} + +/** + * Padding that keeps the controls clear of the safe area (status bar, notch, navigation bar). + * Takes the insets from react-native-safe-area-context, which ships with every Mendix native template. + */ +export function getSafeAreaPadding(insets: EdgeInsets): SafeAreaPadding { + return { + paddingLeft: insets.left + CONTENT_SPACING, + paddingTop: insets.top + CONTENT_SPACING, + paddingRight: insets.right + CONTENT_SPACING, + paddingBottom: insets.bottom + CONTENT_SPACING + }; +} diff --git a/src/NativeVisionCamera.tsx b/src/NativeVisionCamera.tsx index 5f9c928..8a25e4c 100644 --- a/src/NativeVisionCamera.tsx +++ b/src/NativeVisionCamera.tsx @@ -1,6 +1,7 @@ import { ReactElement, createElement, useState, useEffect, Fragment } from "react"; import { TextStyle, ViewStyle } from "react-native"; import { Camera, CameraPermissionStatus } from "react-native-vision-camera"; +import { SafeAreaProvider } from "react-native-safe-area-context"; import { Style } from "@mendix/pluggable-widgets-tools"; @@ -25,5 +26,10 @@ export function NativeVisionCamera(props: NativeVisionCameraProps): return ; } - return ; + // Own provider so the safe area insets are available even if the host app does not render one + return ( + + + + ); } diff --git a/src/components/CameraPage.tsx b/src/components/CameraPage.tsx index eb1ad31..1fcdb2c 100644 --- a/src/components/CameraPage.tsx +++ b/src/components/CameraPage.tsx @@ -1,5 +1,6 @@ import React, { createElement, useEffect, useRef, useState, useMemo, useCallback } from "react"; import { StyleSheet, Text, View, TouchableOpacity, useWindowDimensions } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ActionValue, DynamicValue, EditableValue, ValueStatus } from "mendix"; import { Camera, @@ -13,7 +14,7 @@ import { useLocationPermission, Orientation } from "react-native-vision-camera"; -import { CONTENT_SPACING, SAFE_AREA_PADDING, BUTTON_SIZE, BUTTON_ICON_SIZE, CAPTURE_BUTTON_SIZE } from "../Constants"; +import { CONTENT_SPACING, getSafeAreaPadding, BUTTON_SIZE, BUTTON_ICON_SIZE, CAPTURE_BUTTON_SIZE } from "../Constants"; import { useIsForeground } from "../hooks/useIsForeground"; import { useIsFocused } from "@react-navigation/core"; import { usePreferredCameraDevice } from "../hooks/usePreferredCameraDevice"; @@ -40,6 +41,8 @@ export function CameraPage({ mediaPath, onCaptureAction }: CameraPageProps): Rea const camera = useRef(null); const location = useLocationPermission(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const insets = useSafeAreaInsets(); + const safeAreaPadding = getSafeAreaPadding(insets); const zoom = { value: 1.0 }; // check if camera page is active @@ -156,15 +159,19 @@ export function CameraPage({ mediaPath, onCaptureAction }: CameraPageProps): Rea const photoHdr = format?.supportsPhotoHdr && enableHdr; - const dynamicStyles = - orientation === "landscape" - ? StyleSheet.create({ - captureButtonRing: { - alignSelf: "flex-end", - right: SAFE_AREA_PADDING.paddingBottom - } - }) - : StyleSheet.create({ captureButtonRing: {} }); + // Positions that depend on the safe area insets, which can change at runtime (rotation, edge-to-edge) + const dynamicStyles = { + captureButtonRing: { + bottom: safeAreaPadding.paddingBottom, + ...(orientation === "landscape" + ? { alignSelf: "flex-end" as const, right: safeAreaPadding.paddingBottom } + : {}) + }, + rightButtonRow: { + right: safeAreaPadding.paddingRight, + top: safeAreaPadding.paddingTop + } + }; return ( @@ -207,7 +214,7 @@ export function CameraPage({ mediaPath, onCaptureAction }: CameraPageProps): Rea - + {supportsCameraFlipping && ( @@ -253,7 +260,6 @@ const styles = StyleSheet.create({ justifyContent: "center", position: "absolute", alignSelf: "center", - bottom: SAFE_AREA_PADDING.paddingBottom, padding: 2, width: CAPTURE_BUTTON_SIZE, height: CAPTURE_BUTTON_SIZE, @@ -276,9 +282,7 @@ const styles = StyleSheet.create({ alignItems: "center" }, rightButtonRow: { - position: "absolute", - right: SAFE_AREA_PADDING.paddingRight, - top: SAFE_AREA_PADDING.paddingTop + position: "absolute" }, text: { color: "white", diff --git a/src/components/StatusBarBlurBackground.tsx b/src/components/StatusBarBlurBackground.tsx index 66d6ec9..56e859c 100644 --- a/src/components/StatusBarBlurBackground.tsx +++ b/src/components/StatusBarBlurBackground.tsx @@ -1,18 +1,19 @@ import React, { createElement } from "react"; import { Platform, StyleSheet } from "react-native"; -import StaticSafeAreaInsets from "react-native-static-safe-area-insets"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; import { BlurView, BlurViewProps } from "@react-native-community/blur"; const FALLBACK_COLOR = "rgba(140, 140, 140, 0.3)"; const StatusBarBlurBackgroundImpl = ({ style, ...props }: BlurViewProps): React.ReactElement | null => { + const insets = useSafeAreaInsets(); if (Platform.OS !== "ios") { return null; } return ( Date: Tue, 15 Sep 2026 15:37:24 +0200 Subject: [PATCH 5/6] Bump react-native-vision-camera to 4.7.3 for React Native 0.84 4.6.4 fails to compile against React Native 0.84.1 (native template 19.1): - CameraViewManager.kt: getExportedCustomDirectEventTypeConstants return type - CameraViewModule.kt: unresolved reference 'currentActivity' Both are fixed upstream in 4.7.x; 4.7.3 is the latest 4.x release. Verified with a full Gradle build of native template 19.1.5 (assembleAppstoreDebug). Co-Authored-By: Claude Fable 5.1 --- package-lock.json | 25 +++++++++++++++---------- package.json | 2 +- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3754901..2ae7bcb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,14 +16,14 @@ "react-native-gesture-handler": "2.20.2", "react-native-mmkv": "2.12.2", "react-native-reanimated": "1.13.1", - "react-native-static-safe-area-insets": "2.2.0", - "react-native-vision-camera": "4.6.4" + "react-native-vision-camera": "4.7.3" }, "devDependencies": { "@mendix/pluggable-widgets-tools": "10.18.0", "@types/big.js": "6.2.2", "eslint": "8.49.0", "patch-package": "8.0.0", + "react-native-safe-area-context": "5.7.0", "react-native-svg": "15.15.4" }, "engines": { @@ -13188,11 +13188,16 @@ "react-native": "*" } }, - "node_modules/react-native-static-safe-area-insets": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/react-native-static-safe-area-insets/-/react-native-static-safe-area-insets-2.2.0.tgz", - "integrity": "sha512-TLTW2e2kRK3COSK8gMZzwp4wHguFCtcO18itDLn5av/xQblXt9ylu84o+qD9aKJCBfvtNzGOvqqTKqC5GJRZ/g==", - "license": "MIT" + "node_modules/react-native-safe-area-context": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } }, "node_modules/react-native-svg": { "version": "15.15.4", @@ -13211,9 +13216,9 @@ } }, "node_modules/react-native-vision-camera": { - "version": "4.6.4", - "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.6.4.tgz", - "integrity": "sha512-d998uTHsGJ9bTlsClgT37RK1sK7ZQekv5y6ylynds3vmdK6YbFQJUoi2cvMrFN4dp9qvSlCjEfwekjMIz3guQA==", + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz", + "integrity": "sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA==", "license": "MIT", "peerDependencies": { "@shopify/react-native-skia": "*", diff --git a/package.json b/package.json index 40c9bae..72d0b17 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "react-native-gesture-handler": "2.20.2", "react-native-mmkv": "2.12.2", "react-native-reanimated": "1.13.1", - "react-native-vision-camera": "4.6.4" + "react-native-vision-camera": "4.7.3" }, "overrides": { "react": "18.2.0", From 3aa4a3ac263d17888a8cc5a13a51e48b01c4b207 Mon Sep 17 00:00:00 2001 From: Tim Misker Date: Tue, 15 Sep 2026 16:26:00 +0200 Subject: [PATCH 6/6] Document tested Mendix and native template versions Co-Authored-By: Claude Fable 5.1 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b305e7f..5bfa211 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ VisionCamera is a powerful, high-performance Camera library for React Native. ## Requirements - Mendix 10.18.3+ - react-native 0.75.4+ +- Tested with Mendix 10.24 (native template 14.1) and Mendix 11.12 (native template 19.1, react-native 0.84) ## Development and contribution 1. Install NPM package dependencies by using: `npm install`. If you use NPM v7.x.x, which can be checked by executing `npm -v`, execute: `npm install --legacy-peer-deps`.