mirror of
https://github.com/selfxyz/self.git
synced 2026-01-09 14:48:06 -05:00
Move DocumentCamera screen to the SDK (#1287)
This commit is contained in:
committed by
GitHub
parent
5d04870b36
commit
757ac58995
@@ -137,6 +137,7 @@
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"eslint-plugin-sort-exports": "^0.9.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -149,6 +150,7 @@
|
||||
"vitest": "^2.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lottie-react-native": "7.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-haptic-feedback": "*",
|
||||
|
||||
2366
packages/mobile-sdk-alpha/src/animations/passport_scan.json
Normal file
2366
packages/mobile-sdk-alpha/src/animations/passport_scan.json
Normal file
File diff suppressed because one or more lines are too long
@@ -34,7 +34,18 @@ export type { PassportValidationCallbacks } from './validation/document';
|
||||
export type { SDKEvent, SDKEventMap } from './types/events';
|
||||
export type { SdkErrorCategory } from './errors';
|
||||
|
||||
export {
|
||||
type BottomSectionProps,
|
||||
ExpandableBottomLayout,
|
||||
type FullSectionProps,
|
||||
type LayoutProps,
|
||||
type TopSectionProps,
|
||||
} from './layouts/ExpandableBottomLayout';
|
||||
|
||||
export { DelayedLottieView } from './components/DelayedLottieView.web';
|
||||
|
||||
export { type ProvingStateType } from './proving/provingMachine';
|
||||
|
||||
export { SCANNER_ERROR_CODES, notImplemented, sdkError } from './errors';
|
||||
|
||||
export { SdkEvents } from './types/events';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type { LottieViewProps } from 'lottie-react-native';
|
||||
import LottieView from 'lottie-react-native';
|
||||
import type React from 'react';
|
||||
import { forwardRef, useEffect, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Wrapper around LottieView that fixes iOS native module initialization timing.
|
||||
*
|
||||
* On iOS, the Lottie native module isn't always fully initialized when components
|
||||
* first render during app startup. This causes animations to not appear until
|
||||
* after navigating to another screen that triggers native module initialization.
|
||||
*
|
||||
* This component adds a 100ms delay before starting autoPlay animations, giving
|
||||
* the native module time to initialize properly.
|
||||
*
|
||||
* Usage: Drop-in replacement for LottieView
|
||||
* @example
|
||||
* <DelayedLottieView autoPlay loop source={animation} style={styles.animation} />
|
||||
*/
|
||||
export const DelayedLottieView = forwardRef<LottieView, LottieViewProps>((props, forwardedRef) => {
|
||||
// If LottieView is undefined (peer dependency not installed), return null
|
||||
if (typeof LottieView === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const internalRef = useRef<LottieView>(null);
|
||||
const ref = (forwardedRef as React.RefObject<LottieView>) || internalRef;
|
||||
|
||||
useEffect(() => {
|
||||
// Only auto-trigger for autoPlay animations
|
||||
if (props.autoPlay) {
|
||||
const timer = setTimeout(() => {
|
||||
ref.current?.play();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [props.autoPlay, ref]);
|
||||
|
||||
// For autoPlay animations, disable native autoPlay and control it ourselves
|
||||
const modifiedProps = props.autoPlay ? { ...props, autoPlay: false } : props;
|
||||
|
||||
return <LottieView ref={ref} {...modifiedProps} />;
|
||||
});
|
||||
|
||||
DelayedLottieView.displayName = 'DelayedLottieView';
|
||||
@@ -0,0 +1,10 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
/**
|
||||
* DelayedLottieView for web placeholder component.
|
||||
*/
|
||||
export const DelayedLottieView = () => {
|
||||
return <div />;
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { DimensionValue, NativeSyntheticEvent, ViewProps, ViewStyle } from 'react-native';
|
||||
import { NativeModules, PixelRatio, Platform, requireNativeComponent, StyleSheet, View } from 'react-native';
|
||||
import { NativeModules, PixelRatio, Platform, requireNativeComponent, View } from 'react-native';
|
||||
|
||||
import { extractMRZInfo, formatDateToYYMMDD } from '../mrz';
|
||||
import type { MRZInfo } from '../types/public';
|
||||
@@ -99,7 +99,6 @@ export const MRZScannerView: React.FC<MRZScannerViewProps> = ({
|
||||
);
|
||||
|
||||
const containerStyle = [
|
||||
styles.container,
|
||||
height !== undefined && { height },
|
||||
width !== undefined && { width },
|
||||
aspectRatio !== undefined && { aspectRatio },
|
||||
@@ -112,8 +111,8 @@ export const MRZScannerView: React.FC<MRZScannerViewProps> = ({
|
||||
<NativeMRZScannerView
|
||||
ref={viewRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
width: '130%',
|
||||
height: '130%',
|
||||
}}
|
||||
onPassportRead={handleMRZDetected}
|
||||
onError={handleError}
|
||||
@@ -129,7 +128,7 @@ export const MRZScannerView: React.FC<MRZScannerViewProps> = ({
|
||||
isMounted={true}
|
||||
style={{
|
||||
height: PixelRatio.getPixelSizeForLayoutSize(800),
|
||||
width: PixelRatio.getPixelSizeForLayoutSize(800),
|
||||
width: PixelRatio.getPixelSizeForLayoutSize(400),
|
||||
}}
|
||||
onError={handleError}
|
||||
onPassportRead={handleMRZDetected}
|
||||
@@ -139,18 +138,4 @@ export const MRZScannerView: React.FC<MRZScannerViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// TODO Check this
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
minHeight: 200,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
scanner: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
export const SelfMRZScannerModule = NativeModules.SelfMRZScannerModule;
|
||||
|
||||
@@ -198,6 +198,7 @@ export const View: React.FC<ViewProps> = ({
|
||||
flexGrow,
|
||||
flexShrink,
|
||||
width,
|
||||
maxWidth,
|
||||
height,
|
||||
flexDirection,
|
||||
justifyContent,
|
||||
@@ -226,6 +227,7 @@ export const View: React.FC<ViewProps> = ({
|
||||
...(flexGrow !== undefined && { flexGrow }),
|
||||
...(flexShrink !== undefined && { flexShrink }),
|
||||
...(width !== undefined && { width }),
|
||||
...(maxWidth !== undefined && { maxWidth }),
|
||||
...(height !== undefined && { height }),
|
||||
...(flexDirection && { flexDirection }),
|
||||
...(justifyContent && { justifyContent }),
|
||||
|
||||
5
packages/mobile-sdk-alpha/src/constants/layout.ts
Normal file
5
packages/mobile-sdk-alpha/src/constants/layout.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
export const extraYPadding = 15;
|
||||
@@ -0,0 +1,119 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import passportScanAnimation from 'src/animations/passport_scan.json';
|
||||
import { Additional, Description, SecondaryButton, Title, View, XStack, YStack } from 'src/components';
|
||||
import { DelayedLottieView } from 'src/components/DelayedLottieView';
|
||||
import { MRZScannerView } from 'src/components/MRZScannerView';
|
||||
import { PassportEvents } from 'src/constants/analytics';
|
||||
import { black, slate400, slate800, white } from 'src/constants/colors';
|
||||
import { useSelfClient } from 'src/context';
|
||||
import { mrzReadInstructions, useReadMRZ } from 'src/flows/onboarding/read-mrz';
|
||||
import type { SafeAreaInsets } from 'src/layouts/ExpandableBottomLayout';
|
||||
import { ExpandableBottomLayout } from 'src/layouts/ExpandableBottomLayout';
|
||||
import { SdkEvents } from 'src/types/events';
|
||||
import type { MRZInfo } from 'src/types/public';
|
||||
|
||||
import Scan from '../../../svgs/icons/passport_camera_scan.svg';
|
||||
import { dinot } from '../../constants/fonts';
|
||||
|
||||
type Props = {
|
||||
onBack?: () => void;
|
||||
onSuccess?: () => void;
|
||||
safeAreaInsets?: SafeAreaInsets;
|
||||
};
|
||||
|
||||
export const DocumentCameraScreen = ({ onBack, onSuccess, safeAreaInsets }: Props) => {
|
||||
const scanStartTimeRef = useRef(Date.now());
|
||||
const selfClient = useSelfClient();
|
||||
const { onPassportRead } = useReadMRZ(scanStartTimeRef);
|
||||
|
||||
const handleMRZDetected = useCallback(
|
||||
(mrzData: MRZInfo) => {
|
||||
onPassportRead(null, mrzData);
|
||||
|
||||
onSuccess?.();
|
||||
},
|
||||
[onPassportRead],
|
||||
);
|
||||
|
||||
const handleScannerError = useCallback(
|
||||
(error: string) => {
|
||||
selfClient.emit(SdkEvents.ERROR, new Error(`MRZ scanner error: ${error}`));
|
||||
},
|
||||
[selfClient],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableBottomLayout.Layout
|
||||
backgroundColor={white}
|
||||
safeAreaTop={safeAreaInsets?.top}
|
||||
safeAreaBottom={safeAreaInsets?.bottom}
|
||||
>
|
||||
<ExpandableBottomLayout.TopSection backgroundColor={black} safeAreaTop={safeAreaInsets?.top}>
|
||||
<MRZScannerView onMRZDetected={handleMRZDetected} onError={handleScannerError} />
|
||||
<DelayedLottieView
|
||||
autoPlay
|
||||
loop
|
||||
source={passportScanAnimation}
|
||||
style={styles.animation}
|
||||
cacheComposition={true}
|
||||
renderMode="HARDWARE"
|
||||
/>
|
||||
</ExpandableBottomLayout.TopSection>
|
||||
<ExpandableBottomLayout.BottomSection backgroundColor={white} safeAreaBottom={safeAreaInsets?.bottom}>
|
||||
<YStack alignItems="center" gap="$2.5">
|
||||
<YStack alignItems="center" gap="$6" paddingBottom="$2.5">
|
||||
<Title>Scan your ID</Title>
|
||||
<XStack gap="$6" alignSelf="flex-start" alignItems="flex-start">
|
||||
<View paddingTop="$2">
|
||||
<Scan height={40} width={40} color={slate800} />
|
||||
</View>
|
||||
<View maxWidth="75%">
|
||||
<Description style={styles.subheader}>Open to the photograph page</Description>
|
||||
<Additional style={styles.description}>{mrzReadInstructions()}</Additional>
|
||||
</View>
|
||||
</XStack>
|
||||
</YStack>
|
||||
|
||||
<Additional style={styles.disclaimer}>SELF WILL NOT CAPTURE AN IMAGE OF YOUR PASSPORT.</Additional>
|
||||
|
||||
<SecondaryButton trackEvent={PassportEvents.CAMERA_SCREEN_CLOSED} onPress={onBack ?? (() => {})}>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
</YStack>
|
||||
</ExpandableBottomLayout.BottomSection>
|
||||
</ExpandableBottomLayout.Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
animation: {
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
subheader: {
|
||||
color: slate800,
|
||||
textAlign: 'left',
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
description: {
|
||||
textAlign: 'left',
|
||||
},
|
||||
disclaimer: {
|
||||
fontFamily: dinot,
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
color: slate400,
|
||||
textTransform: 'uppercase',
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
letterSpacing: 0.44,
|
||||
marginTop: 0,
|
||||
marginBottom: 10,
|
||||
},
|
||||
});
|
||||
@@ -49,6 +49,17 @@ export type { SDKEvent, SDKEventMap } from './types/events';
|
||||
export type { SdkErrorCategory } from './errors';
|
||||
// Screen Components (React Native-based)
|
||||
export type { provingMachineCircuitType } from './proving/provingMachine';
|
||||
|
||||
export {
|
||||
type BottomSectionProps,
|
||||
ExpandableBottomLayout,
|
||||
type FullSectionProps,
|
||||
type LayoutProps,
|
||||
type TopSectionProps,
|
||||
} from './layouts/ExpandableBottomLayout';
|
||||
|
||||
export { DelayedLottieView } from './components/DelayedLottieView';
|
||||
|
||||
export {
|
||||
InitError,
|
||||
LivenessError,
|
||||
@@ -59,10 +70,8 @@ export {
|
||||
notImplemented,
|
||||
sdkError,
|
||||
} from './errors';
|
||||
export { NFCScannerScreen } from './components/screens/NFCScannerScreen';
|
||||
|
||||
// Context and Client
|
||||
export { PassportCameraScreen } from './components/screens/PassportCameraScreen';
|
||||
export { NFCScannerScreen } from './components/screens/NFCScannerScreen';
|
||||
|
||||
export { type ProvingStateType } from './proving/provingMachine';
|
||||
// Components
|
||||
|
||||
175
packages/mobile-sdk-alpha/src/layouts/ExpandableBottomLayout.tsx
Normal file
175
packages/mobile-sdk-alpha/src/layouts/ExpandableBottomLayout.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import type React from 'react';
|
||||
import { Dimensions, PixelRatio, Platform, ScrollView, StyleSheet } from 'react-native';
|
||||
|
||||
import type { ViewProps } from '../components';
|
||||
import { View } from '../components';
|
||||
import { black, white } from '../constants/colors';
|
||||
import { extraYPadding } from '../constants/layout';
|
||||
|
||||
const SAFE_AREA_TOP_DEFAULT = 0;
|
||||
const SAFE_AREA_BOTTOM_DEFAULT = 0;
|
||||
|
||||
// Get the current font scale factor
|
||||
const fontScale = PixelRatio.getFontScale();
|
||||
// fontScale > 1 means the user has increased text size in accessibility settings
|
||||
const isLargerTextEnabled = fontScale > 1.3;
|
||||
|
||||
export interface BottomSectionProps extends ViewProps {
|
||||
children: React.ReactNode;
|
||||
backgroundColor: string;
|
||||
safeAreaBottom?: number;
|
||||
}
|
||||
|
||||
export type FullSectionProps = ViewProps & {
|
||||
safeAreaTop?: number;
|
||||
safeAreaBottom?: number;
|
||||
};
|
||||
|
||||
export interface LayoutProps extends ViewProps {
|
||||
children: React.ReactNode;
|
||||
backgroundColor: string;
|
||||
safeAreaTop?: number;
|
||||
safeAreaBottom?: number;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children, backgroundColor }) => {
|
||||
return (
|
||||
<View flex={1} flexDirection="column" backgroundColor={backgroundColor}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const TopSection: React.FC<TopSectionProps> = ({ children, backgroundColor, ...props }) => {
|
||||
const { safeAreaTop = SAFE_AREA_TOP_DEFAULT } = props;
|
||||
const { roundTop, ...restProps } = props;
|
||||
|
||||
return (
|
||||
<View
|
||||
{...restProps}
|
||||
backgroundColor={backgroundColor}
|
||||
style={[
|
||||
styles.topSection,
|
||||
roundTop && styles.roundTop,
|
||||
roundTop ? { marginTop: safeAreaTop } : { paddingTop: safeAreaTop },
|
||||
{ backgroundColor },
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* This component is a layout that has a top and bottom section. Bottom section
|
||||
* automatically expands to as much space as it needs while the top section
|
||||
* takes up the remaining space.
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* import { ExpandableBottomLayout } from '../components/ExpandableBottomLayout';
|
||||
*
|
||||
* <ExpandableBottomLayout.Layout>
|
||||
* <ExpandableBottomLayout.TopSection>
|
||||
* <...top section content...>
|
||||
* </ExpandableBottomLayout.TopSection>
|
||||
* <ExpandableBottomLayout.BottomSection>
|
||||
* <...bottom section content...>
|
||||
* </ExpandableBottomLayout.BottomSection>
|
||||
* </ExpandableBottomLayout.Layout>
|
||||
*/
|
||||
export type SafeAreaInsets = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
};
|
||||
/*
|
||||
* Rather than using a top and bottom section, this component is te entire thing.
|
||||
* It leave space for the safe area insets and provides basic padding
|
||||
*/
|
||||
const FullSection: React.FC<FullSectionProps> = ({ children, backgroundColor, ...props }: FullSectionProps) => {
|
||||
const { safeAreaTop = SAFE_AREA_TOP_DEFAULT, safeAreaBottom = SAFE_AREA_BOTTOM_DEFAULT } = props;
|
||||
return (
|
||||
<View
|
||||
paddingHorizontal={20}
|
||||
backgroundColor={backgroundColor}
|
||||
paddingTop={safeAreaTop}
|
||||
paddingBottom={safeAreaBottom}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const BottomSection: React.FC<BottomSectionProps> = ({ children, style, ...props }) => {
|
||||
const incomingBottom = props.paddingBottom ?? 0;
|
||||
const { safeAreaBottom = SAFE_AREA_BOTTOM_DEFAULT } = props;
|
||||
const minBottom = safeAreaBottom + extraYPadding;
|
||||
const totalBottom = typeof incomingBottom === 'number' ? minBottom + incomingBottom : minBottom;
|
||||
|
||||
let panelHeight: number | 'auto' = 'auto';
|
||||
// set bottom section height to 38% of screen height
|
||||
// and wrap children in a scroll view if larger text is enabled
|
||||
if (isLargerTextEnabled) {
|
||||
const windowHeight = Dimensions.get('window').height;
|
||||
panelHeight = windowHeight * 0.38;
|
||||
children = (
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ flexGrow: 1 }}>
|
||||
{children}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View {...props} height={panelHeight} style={[styles.bottomSection, style]} paddingBottom={totalBottom}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TopSectionProps extends ViewProps {
|
||||
children: React.ReactNode;
|
||||
backgroundColor: string;
|
||||
roundTop?: boolean;
|
||||
safeAreaTop?: number;
|
||||
}
|
||||
|
||||
export const ExpandableBottomLayout = {
|
||||
Layout,
|
||||
TopSection,
|
||||
FullSection,
|
||||
BottomSection,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
roundTop: {
|
||||
marginTop: 12,
|
||||
overflow: 'hidden',
|
||||
borderTopRightRadius: 30,
|
||||
borderTopLeftRadius: 30,
|
||||
},
|
||||
layout: {
|
||||
height: '100%',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
topSection: {
|
||||
alignSelf: 'stretch',
|
||||
flexGrow: 1,
|
||||
flexShrink: Platform.select({ web: 0, default: 1 }),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: black,
|
||||
overflow: 'hidden',
|
||||
padding: 20,
|
||||
},
|
||||
bottomSection: {
|
||||
backgroundColor: white,
|
||||
paddingTop: 30,
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 35 35" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.8516 27.9321C11.8148 27.9321 11.0229 27.6587 10.4761 27.1118C9.94059 26.5649 9.67285 25.7617 9.67285 24.7021V10.603C9.67285 9.55485 9.94059 8.75732 10.4761 8.21045C11.0229 7.66357 11.8148 7.39014 12.8516 7.39014H16.3892V15.1831C16.3892 15.7756 16.5487 16.237 16.8677 16.5674C17.1981 16.8978 17.6595 17.063 18.252 17.063H26.062V24.7021C26.062 25.7617 25.7886 26.5649 25.2417 27.1118C24.7062 27.6587 23.9144 27.9321 22.8662 27.9321H12.8516ZM18.8159 15.1831C18.4513 15.1831 18.269 15.0008 18.269 14.6362V7.47559C18.4741 7.50977 18.6849 7.60091 18.9014 7.74902C19.1292 7.89714 19.3685 8.09652 19.6191 8.34717L25.0708 13.8159C25.3328 14.0666 25.5379 14.3058 25.686 14.5337C25.8341 14.7502 25.9253 14.9666 25.9595 15.1831H18.8159ZM2.23877 12.1582C1.12223 12.1582 0.563965 11.5885 0.563965 10.4492V6.29639C0.563965 4.47347 1.03678 3.09489 1.98242 2.16064C2.92806 1.2264 4.32373 0.759277 6.16943 0.759277H10.3223C11.4502 0.759277 12.0142 1.31755 12.0142 2.43408C12.0142 3.56201 11.4502 4.12598 10.3223 4.12598H6.37451C5.57699 4.12598 4.96745 4.33105 4.5459 4.74121C4.13574 5.15137 3.93066 5.7723 3.93066 6.604V10.4492C3.93066 11.5885 3.3667 12.1582 2.23877 12.1582ZM32.7441 12.1582C31.6276 12.1582 31.0693 11.5885 31.0693 10.4492V6.604C31.0693 5.7723 30.8529 5.15137 30.4199 4.74121C29.9984 4.33105 29.3945 4.12598 28.6084 4.12598H24.6606C23.5327 4.12598 22.9688 3.56201 22.9688 2.43408C22.9688 1.31755 23.5327 0.759277 24.6606 0.759277H28.8135C30.6706 0.759277 32.0719 1.2264 33.0176 2.16064C33.9632 3.09489 34.436 4.47347 34.436 6.29639V10.4492C34.436 11.5885 33.8721 12.1582 32.7441 12.1582ZM6.16943 34.6143C4.32373 34.6143 2.92806 34.1414 1.98242 33.1958C1.03678 32.2616 0.563965 30.883 0.563965 29.0601V24.9243C0.563965 23.785 1.12223 23.2153 2.23877 23.2153C3.3667 23.2153 3.93066 23.785 3.93066 24.9243V28.7695C3.93066 29.6012 4.13574 30.2222 4.5459 30.6323C4.96745 31.0425 5.57699 31.2476 6.37451 31.2476H10.3223C11.4502 31.2476 12.0142 31.8115 12.0142 32.9395C12.0142 34.056 11.4502 34.6143 10.3223 34.6143H6.16943ZM24.6606 34.6143C23.5327 34.6143 22.9688 34.056 22.9688 32.9395C22.9688 31.8115 23.5327 31.2476 24.6606 31.2476H28.6084C29.3945 31.2476 29.9984 31.0425 30.4199 30.6323C30.8529 30.2222 31.0693 29.6012 31.0693 28.7695V24.9243C31.0693 23.785 31.6276 23.2153 32.7441 23.2153C33.8721 23.2153 34.436 23.785 34.436 24.9243V29.0601C34.436 30.883 33.9632 32.2616 33.0176 33.1958C32.0719 34.1414 30.6706 34.6143 28.8135 34.6143H24.6606Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -67,6 +67,12 @@ vi.mock('react-native', () => ({
|
||||
Dimensions: {
|
||||
get: vi.fn(() => ({ width: 375, height: 667 })),
|
||||
},
|
||||
PixelRatio: {
|
||||
get: vi.fn(() => 2),
|
||||
getFontScale: vi.fn(() => 1),
|
||||
getPixelSizeForLayoutSize: vi.fn((size: number) => size * 2),
|
||||
roundToNearestPixel: vi.fn((size: number) => Math.round(size)),
|
||||
},
|
||||
StatusBar: {
|
||||
setBarStyle: vi.fn(),
|
||||
},
|
||||
@@ -192,3 +198,56 @@ vi.mock('react-native-localize', () => ({
|
||||
isRTL: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock react-native-svg
|
||||
vi.mock('react-native-svg', () => ({
|
||||
default: 'svg',
|
||||
Svg: 'svg',
|
||||
Circle: 'circle',
|
||||
Ellipse: 'ellipse',
|
||||
G: 'g',
|
||||
Text: 'text',
|
||||
TSpan: 'tspan',
|
||||
TextPath: 'textPath',
|
||||
Path: 'path',
|
||||
Polygon: 'polygon',
|
||||
Polyline: 'polyline',
|
||||
Line: 'line',
|
||||
Rect: 'rect',
|
||||
Use: 'use',
|
||||
Image: 'image',
|
||||
Symbol: 'symbol',
|
||||
Defs: 'defs',
|
||||
LinearGradient: 'linearGradient',
|
||||
RadialGradient: 'radialGradient',
|
||||
Stop: 'stop',
|
||||
ClipPath: 'clipPath',
|
||||
Pattern: 'pattern',
|
||||
Mask: 'mask',
|
||||
}));
|
||||
|
||||
// Mock react-native-svg-circle-country-flags
|
||||
vi.mock('react-native-svg-circle-country-flags', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
// Mock lottie-react-native
|
||||
vi.mock('lottie-react-native', () => ({
|
||||
default: 'div',
|
||||
}));
|
||||
|
||||
// Mock react-native-haptic-feedback
|
||||
vi.mock('react-native-haptic-feedback', () => ({
|
||||
default: {
|
||||
trigger: vi.fn(),
|
||||
},
|
||||
HapticFeedbackTypes: {
|
||||
impactLight: 'impactLight',
|
||||
impactMedium: 'impactMedium',
|
||||
impactHeavy: 'impactHeavy',
|
||||
notificationSuccess: 'notificationSuccess',
|
||||
notificationWarning: 'notificationWarning',
|
||||
notificationError: 'notificationError',
|
||||
selection: 'selection',
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -67,8 +67,11 @@ const config = {
|
||||
unstable_conditionNames: ['react-native', 'import', 'require'],
|
||||
unstable_enableSymlinks: true,
|
||||
nodeModulesPaths: [path.resolve(projectRoot, 'node_modules'), path.resolve(workspaceRoot, 'node_modules')],
|
||||
|
||||
// SVG support
|
||||
assetExts: assetExts.filter(ext => ext !== 'svg'),
|
||||
sourceExts: [...sourceExts, 'svg'],
|
||||
|
||||
extraNodeModules: {
|
||||
// Add workspace packages for proper resolution
|
||||
'@selfxyz/common': path.resolve(workspaceRoot, 'common'),
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"buffer": "^6.0.3",
|
||||
"constants-browserify": "^1.0.0",
|
||||
"ethers": "^6.11.0",
|
||||
"lottie-react": "^2.4.1",
|
||||
"lottie-react-native": "7.2.2",
|
||||
"react": "^18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-get-random-values": "^1.11.0",
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AccessibilityInfo, PermissionsAndroid, Platform } from 'react-native';
|
||||
|
||||
import type { MRZInfo } from '@selfxyz/mobile-sdk-alpha';
|
||||
import { useReadMRZ } from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz';
|
||||
|
||||
import type { NormalizedMRZResult } from '../utils/camera';
|
||||
import { normalizeMRZPayload } from '../utils/camera';
|
||||
|
||||
type PermissionState = 'loading' | 'granted' | 'denied';
|
||||
type ScanState = 'idle' | 'scanning' | 'success' | 'error';
|
||||
|
||||
function announceForAccessibility(message: string) {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
AccessibilityInfo.announceForAccessibility?.(message);
|
||||
} catch {
|
||||
// Intentionally swallow to avoid crashing accessibility users on announce failures.
|
||||
}
|
||||
}
|
||||
|
||||
export interface DocumentScannerCopy {
|
||||
instructions: string;
|
||||
success: string;
|
||||
error: string;
|
||||
permissionDenied: string;
|
||||
resetAnnouncement: string;
|
||||
}
|
||||
|
||||
export interface DocumentScannerState {
|
||||
permissionStatus: PermissionState;
|
||||
scanState: ScanState;
|
||||
mrzResult: NormalizedMRZResult | null;
|
||||
error: string | null;
|
||||
requestPermission: () => Promise<void>;
|
||||
handleMRZDetected: (payload: MRZInfo) => void;
|
||||
handleScannerError: (error: string) => void;
|
||||
handleScanAgain: () => void;
|
||||
}
|
||||
|
||||
export function useMRZScanner(copy: DocumentScannerCopy): DocumentScannerState {
|
||||
const [permissionStatus, setPermissionStatus] = useState<PermissionState>('loading');
|
||||
const [scanState, setScanState] = useState<ScanState>('idle');
|
||||
const [mrzResult, setMrzResult] = useState<NormalizedMRZResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const scanStartTimeRef = useRef<number>(Date.now());
|
||||
const { onPassportRead } = useReadMRZ(scanStartTimeRef);
|
||||
|
||||
const requestPermission = useCallback(async () => {
|
||||
setPermissionStatus('loading');
|
||||
setError(null);
|
||||
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA, {
|
||||
title: 'Camera permission',
|
||||
message: 'We need your permission to access the camera for MRZ scanning.',
|
||||
buttonPositive: 'Allow',
|
||||
buttonNegative: 'Cancel',
|
||||
buttonNeutral: 'Ask me later',
|
||||
});
|
||||
|
||||
if (result === PermissionsAndroid.RESULTS.GRANTED) {
|
||||
setPermissionStatus('granted');
|
||||
} else {
|
||||
setPermissionStatus('denied');
|
||||
}
|
||||
} catch {
|
||||
setPermissionStatus('denied');
|
||||
setError('Camera permission request failed. Please try again.');
|
||||
}
|
||||
} else {
|
||||
setPermissionStatus('granted');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
requestPermission();
|
||||
}, [requestPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionStatus === 'granted') {
|
||||
announceForAccessibility(copy.instructions);
|
||||
setScanState(current => {
|
||||
if (current === 'success') {
|
||||
return current;
|
||||
}
|
||||
scanStartTimeRef.current = Date.now();
|
||||
return 'scanning';
|
||||
});
|
||||
} else if (permissionStatus === 'denied') {
|
||||
announceForAccessibility(copy.permissionDenied);
|
||||
setScanState('idle');
|
||||
}
|
||||
}, [copy.instructions, copy.permissionDenied, permissionStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scanState === 'success') {
|
||||
announceForAccessibility(copy.success);
|
||||
} else if (scanState === 'error') {
|
||||
announceForAccessibility(copy.error);
|
||||
}
|
||||
}, [copy.error, copy.success, scanState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
announceForAccessibility(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleMRZDetected = useCallback(
|
||||
(payload: MRZInfo) => {
|
||||
setError(null);
|
||||
|
||||
setScanState(current => {
|
||||
if (current === 'success') {
|
||||
return current;
|
||||
}
|
||||
return 'scanning';
|
||||
});
|
||||
|
||||
try {
|
||||
const normalized = normalizeMRZPayload(payload);
|
||||
setMrzResult(normalized);
|
||||
setScanState('success');
|
||||
onPassportRead(null, normalized.info);
|
||||
} catch {
|
||||
setScanState('error');
|
||||
setError('Unable to validate the MRZ data from the scan.');
|
||||
}
|
||||
},
|
||||
[onPassportRead],
|
||||
);
|
||||
|
||||
const handleScannerError = useCallback((scannerError: string) => {
|
||||
setScanState('error');
|
||||
setError(scannerError || 'An unexpected camera error occurred.');
|
||||
}, []);
|
||||
|
||||
const handleScanAgain = useCallback(() => {
|
||||
if (permissionStatus === 'denied') {
|
||||
requestPermission();
|
||||
return;
|
||||
}
|
||||
|
||||
scanStartTimeRef.current = Date.now();
|
||||
setMrzResult(null);
|
||||
setError(null);
|
||||
setScanState('scanning');
|
||||
announceForAccessibility(copy.resetAnnouncement);
|
||||
}, [copy.resetAnnouncement, permissionStatus, requestPermission]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
permissionStatus,
|
||||
scanState,
|
||||
mrzResult,
|
||||
error,
|
||||
requestPermission,
|
||||
handleMRZDetected,
|
||||
handleScannerError,
|
||||
handleScanAgain,
|
||||
}),
|
||||
[
|
||||
error,
|
||||
handleMRZDetected,
|
||||
handleScanAgain,
|
||||
handleScannerError,
|
||||
mrzResult,
|
||||
permissionStatus,
|
||||
requestPermission,
|
||||
scanState,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -2,225 +2,19 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { ActivityIndicator, Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import React from 'react';
|
||||
|
||||
import { MRZScannerView } from '@selfxyz/mobile-sdk-alpha/onboarding/read-mrz';
|
||||
|
||||
import ScreenLayout from '../components/ScreenLayout';
|
||||
import DocumentScanResultCard from '../components/DocumentScanResultCard';
|
||||
import { useMRZScanner } from '../hooks/useMRZScanner';
|
||||
import { DocumentCameraScreen } from '@selfxyz/mobile-sdk-alpha/onboarding/document-camera-screen';
|
||||
|
||||
type Props = {
|
||||
onBack: () => void;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const instructionsText = 'Align the machine-readable text with the frame and hold steady while we scan.';
|
||||
|
||||
const successMessage = 'Document scan successful. Review the details below.';
|
||||
const errorMessage = 'We could not read your document. Adjust lighting and try again.';
|
||||
const permissionDeniedMessage = 'Camera access was denied. Enable permissions to scan your document.';
|
||||
|
||||
export default function DocumentCamera({ onBack }: Props) {
|
||||
const scannerCopy = {
|
||||
instructions: instructionsText,
|
||||
success: successMessage,
|
||||
error: errorMessage,
|
||||
permissionDenied: permissionDeniedMessage,
|
||||
resetAnnouncement: 'Ready to scan again. Align the document in the viewfinder.',
|
||||
} as const;
|
||||
|
||||
const {
|
||||
permissionStatus,
|
||||
scanState,
|
||||
mrzResult,
|
||||
error,
|
||||
requestPermission,
|
||||
handleMRZDetected,
|
||||
handleScannerError,
|
||||
handleScanAgain,
|
||||
} = useMRZScanner(scannerCopy);
|
||||
|
||||
const handleSaveDocument = useCallback(() => {
|
||||
if (!mrzResult) {
|
||||
Alert.alert('Save Document', 'Scan a document before attempting to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'Save Document',
|
||||
'Document storage will be available in a future release. Your scan is ready when you need it.',
|
||||
);
|
||||
}, [mrzResult]);
|
||||
|
||||
const renderPermissionDenied = () => (
|
||||
<View style={styles.centeredState}>
|
||||
<Text style={styles.permissionText}>{permissionDeniedMessage}</Text>
|
||||
<TouchableOpacity accessibilityRole="button" style={styles.secondaryButton} onPress={requestPermission}>
|
||||
<Text style={styles.secondaryButtonText}>Request Permission</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderLoading = () => (
|
||||
<View style={styles.centeredState}>
|
||||
<ActivityIndicator accessibilityLabel="Loading camera" color="#0f172a" />
|
||||
<Text style={styles.statusText}>Preparing camera…</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
export default function DocumentCamera({ onBack, onSuccess }: Props) {
|
||||
return (
|
||||
<ScreenLayout
|
||||
title="Document Camera"
|
||||
onBack={() => {
|
||||
onBack();
|
||||
}}
|
||||
contentStyle={styles.screenContent}
|
||||
>
|
||||
{permissionStatus === 'loading' && renderLoading()}
|
||||
{permissionStatus === 'denied' && renderPermissionDenied()}
|
||||
|
||||
{permissionStatus === 'granted' && (
|
||||
<View style={styles.contentWrapper}>
|
||||
<View style={styles.instructionsContainer}>
|
||||
<Text style={styles.instructionsTitle}>Position your document</Text>
|
||||
<Text style={styles.instructionsText}>{instructionsText}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.cameraWrapper}>
|
||||
<MRZScannerView style={styles.scanner} onMRZDetected={handleMRZDetected} onError={handleScannerError} />
|
||||
</View>
|
||||
|
||||
<View style={styles.statusContainer}>
|
||||
{scanState === 'scanning' && !error && (
|
||||
<View style={styles.statusRow}>
|
||||
<ActivityIndicator accessibilityLabel="Scanning" color="#2563eb" size="small" />
|
||||
<Text style={styles.statusText}>Scanning for MRZ data…</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{scanState === 'success' && mrzResult && (
|
||||
<Text style={[styles.statusText, styles.successText]}>{successMessage}</Text>
|
||||
)}
|
||||
|
||||
{scanState === 'error' && error && <Text style={[styles.statusText, styles.errorText]}>{error}</Text>}
|
||||
</View>
|
||||
|
||||
<View style={[styles.actions, mrzResult && styles.actionsWithResult]}>
|
||||
<TouchableOpacity accessibilityRole="button" onPress={handleScanAgain} style={styles.secondaryButton}>
|
||||
<Text style={styles.secondaryButtonText}>Scan Again</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity accessibilityRole="button" onPress={handleSaveDocument} style={styles.primaryButton}>
|
||||
<Text style={styles.primaryButtonText}>Save Document</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{mrzResult && <DocumentScanResultCard result={mrzResult} />}
|
||||
</View>
|
||||
)}
|
||||
</ScreenLayout>
|
||||
<>
|
||||
<DocumentCameraScreen onBack={onBack} onSuccess={onSuccess} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screenContent: {
|
||||
gap: 16,
|
||||
},
|
||||
contentWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
instructionsContainer: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
instructionsTitle: {
|
||||
color: '#0f172a',
|
||||
fontWeight: '600',
|
||||
fontSize: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
instructionsText: {
|
||||
color: '#475569',
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
cameraWrapper: {
|
||||
backgroundColor: '#0f172a',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
minHeight: 260,
|
||||
marginBottom: 16,
|
||||
},
|
||||
scanner: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: '#0f172a',
|
||||
},
|
||||
statusContainer: {
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
statusText: {
|
||||
color: '#0f172a',
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
},
|
||||
successText: {
|
||||
color: '#15803d',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorText: {
|
||||
color: '#b91c1c',
|
||||
fontWeight: '600',
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
actionsWithResult: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
primaryButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0f172a',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#ffffff',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secondaryButton: {
|
||||
flex: 1,
|
||||
backgroundColor: '#e2e8f0',
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: '#0f172a',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
centeredState: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
permissionText: {
|
||||
color: '#0f172a',
|
||||
textAlign: 'center',
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -74,7 +74,10 @@ export const screenDescriptors: ScreenDescriptor[] = [
|
||||
sectionTitle: '📸 Scanning',
|
||||
status: 'placeholder',
|
||||
load: () => require('./DocumentCamera').default,
|
||||
getProps: ({ navigate }) => ({ onBack: () => navigate('home') }),
|
||||
getProps: ({ navigate }) => ({
|
||||
onBack: () => navigate('home'),
|
||||
onSuccess: () => navigate('nfc'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 'nfc',
|
||||
|
||||
@@ -8,8 +8,9 @@ import DocumentCamera from '../../src/screens/DocumentCamera';
|
||||
describe('DocumentCamera screen', () => {
|
||||
it('shows placeholder messaging and handles back navigation', async () => {
|
||||
const onBack = vi.fn();
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
render(<DocumentCamera onBack={onBack} />);
|
||||
render(<DocumentCamera onBack={onBack} onSuccess={onSuccess} />);
|
||||
|
||||
expect(screen.getByText('Document Camera')).toBeInTheDocument();
|
||||
expect(screen.getByText(/camera-based document scanning/i)).toBeInTheDocument();
|
||||
|
||||
Reference in New Issue
Block a user