-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(mobile): [Scanner] add scanner view and integrate with AI api (#129
) Co-authored-by: Quốc Khánh <[email protected]>
- Loading branch information
Showing
17 changed files
with
529 additions
and
75 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,11 +1,188 @@ | ||
import { Toolbar } from '@/components/common/toolbar' | ||
import { Text, View } from 'react-native' | ||
import { AnimatedRing } from '@/components/scanner/animated-ring' | ||
// import { ScanningIndicator } from '@/components/scanner/scanning-indicator' | ||
import { ScanningOverlay } from '@/components/scanner/scanning-overlay' | ||
import { Button } from '@/components/ui/button' | ||
import { Text } from '@/components/ui/text' | ||
import { getAITransactionData } from '@/mutations/transaction' | ||
import { t } from '@lingui/macro' | ||
import { useLingui } from '@lingui/react' | ||
import { useMutation } from '@tanstack/react-query' | ||
import { type CameraType, CameraView, useCameraPermissions } from 'expo-camera' | ||
import * as Haptics from 'expo-haptics' | ||
import { SaveFormat, manipulateAsync } from 'expo-image-manipulator' | ||
import * as ImagePicker from 'expo-image-picker' | ||
import { useRouter } from 'expo-router' | ||
import { | ||
CameraIcon, | ||
ImagesIcon, | ||
LoaderIcon, | ||
SwitchCameraIcon, | ||
} from 'lucide-react-native' | ||
import { cssInterop } from 'nativewind' | ||
import { useRef, useState } from 'react' | ||
import { Alert } from 'react-native' | ||
import { ImageBackground, View } from 'react-native' | ||
|
||
cssInterop(CameraView, { | ||
className: { | ||
target: 'style', | ||
}, | ||
}) | ||
|
||
export default function ScannerScreen() { | ||
const camera = useRef<CameraView>(null) | ||
const router = useRouter() | ||
const [facing, setFacing] = useState<CameraType>('back') | ||
const [permission, requestPermission] = useCameraPermissions() | ||
const [imageUri, setImageUri] = useState<string | null>(null) | ||
const { i18n } = useLingui() | ||
|
||
const { mutateAsync } = useMutation({ | ||
mutationFn: getAITransactionData, | ||
onError(error) { | ||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) | ||
Alert.alert(error.message ?? t(i18n)`Cannot extract transaction data`) | ||
setImageUri(null) | ||
}, | ||
onSuccess(result) { | ||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) | ||
router.push({ | ||
pathname: '/transaction/new-record', | ||
// biome-ignore lint/suspicious/noExplicitAny: <explanation> | ||
params: result as any, | ||
}) | ||
setImageUri(null) | ||
}, | ||
}) | ||
|
||
function toggleFacing() { | ||
Haptics.selectionAsync() | ||
setFacing(facing === 'back' ? 'front' : 'back') | ||
} | ||
|
||
async function processImage(uri: string) { | ||
const manipResult = await manipulateAsync( | ||
uri, | ||
[ | ||
{ | ||
resize: { width: 1024 }, | ||
}, | ||
], | ||
{ | ||
compress: 0.5, | ||
format: SaveFormat.WEBP, | ||
}, | ||
) | ||
setImageUri(manipResult.uri) | ||
await mutateAsync(manipResult.uri) | ||
} | ||
|
||
async function takePicture() { | ||
Haptics.selectionAsync() | ||
const result = await camera.current?.takePictureAsync({ | ||
scale: 0.5, | ||
quality: 0.5, | ||
}) | ||
if (result?.uri) { | ||
return await processImage(result.uri) | ||
} | ||
} | ||
|
||
async function pickImage() { | ||
Haptics.selectionAsync() | ||
const result = await ImagePicker.launchImageLibraryAsync({ | ||
allowsMultipleSelection: false, | ||
mediaTypes: ImagePicker.MediaTypeOptions.Images, | ||
allowsEditing: false, | ||
quality: 0.5, | ||
}) | ||
if (result.canceled) { | ||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error) | ||
return | ||
} | ||
return await processImage(result.assets[0].uri) | ||
} | ||
|
||
if (!permission) { | ||
// Camera permissions are still loading. | ||
return ( | ||
<View className="flex-1 items-center bg-muted justify-center"> | ||
<LoaderIcon className="size-7 animate-spin text-primary" /> | ||
</View> | ||
) | ||
} | ||
|
||
if (!permission.granted) { | ||
// Camera permissions are not granted. | ||
return ( | ||
<View className="flex-1 items-center bg-muted gap-4 justify-center"> | ||
<CameraIcon className="size-16 text-muted-foreground" /> | ||
<Text>{t(i18n)`Camera permissions are not granted`}</Text> | ||
<Button variant="outline" onPress={requestPermission}> | ||
<Text>{t(i18n)`Grant camera permissions`}</Text> | ||
</Button> | ||
</View> | ||
) | ||
} | ||
|
||
if (imageUri) { | ||
return ( | ||
<View className="flex-1 bg-primary relative"> | ||
<ImageBackground | ||
source={{ uri: imageUri }} | ||
className="flex-1 items-center" | ||
> | ||
<ScanningOverlay /> | ||
{/* <ScanningIndicator /> */} | ||
<View className="top-6 bg-background p-2 px-4 rounded-md"> | ||
<Text>{t(i18n)`Processing transaction...`}</Text> | ||
</View> | ||
<Button | ||
variant="secondary" | ||
size="icon" | ||
className="w-auto !opacity-100 h-auto p-1 absolute bottom-6 rounded-full bg-primary-foreground" | ||
disabled | ||
> | ||
<AnimatedRing /> | ||
</Button> | ||
</ImageBackground> | ||
</View> | ||
) | ||
} | ||
|
||
return ( | ||
<View className="flex-1 bg-card"> | ||
<Text className="font-sans">Scanner Screen</Text> | ||
<Toolbar /> | ||
<CameraView ref={camera} className="flex-1 items-center" facing={facing}> | ||
<View className="top-6 bg-background/50 p-2 px-4 rounded-md"> | ||
<Text>{t(i18n)`Take a picture of your transaction`}</Text> | ||
</View> | ||
<View className="absolute bottom-6 left-6 right-6 flex-row items-center justify-between gap-4"> | ||
<Button | ||
variant="secondary" | ||
size="icon" | ||
className="rounded-full w-12 h-12" | ||
onPress={pickImage} | ||
> | ||
<ImagesIcon className="size-6 text-primary" /> | ||
</Button> | ||
<Button | ||
variant="secondary" | ||
size="icon" | ||
className="w-auto h-auto p-1 rounded-full bg-primary-foreground" | ||
onPress={takePicture} | ||
> | ||
<View className="w-16 h-16 bg-primary-foreground border-2 border-primary rounded-full" /> | ||
</Button> | ||
<Button | ||
variant="secondary" | ||
size="icon" | ||
className="rounded-full w-12 h-12" | ||
onPress={toggleFacing} | ||
> | ||
<SwitchCameraIcon className="size-6 text-primary" /> | ||
</Button> | ||
</View> | ||
</CameraView> | ||
</View> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { cn } from '@/lib/utils' | ||
import { useEffect } from 'react' | ||
import Animated, { | ||
useAnimatedStyle, | ||
useSharedValue, | ||
withRepeat, | ||
withSequence, | ||
withTiming, | ||
} from 'react-native-reanimated' | ||
|
||
const duration = 1000 | ||
|
||
export function AnimatedRing({ | ||
className, | ||
...props | ||
}: Omit<React.ComponentPropsWithoutRef<typeof Animated.View>, 'style'>) { | ||
const sv = useSharedValue(1) | ||
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | ||
useEffect(() => { | ||
sv.value = withRepeat( | ||
withSequence(withTiming(0, { duration }), withTiming(1, { duration })), | ||
-1, | ||
) | ||
}, []) | ||
|
||
const style = useAnimatedStyle(() => ({ | ||
opacity: sv.value, | ||
})) | ||
|
||
return ( | ||
<Animated.View | ||
style={[style]} | ||
className={cn( | ||
'w-16 h-16 bg-primary-foreground border-2 border-primary rounded-full', | ||
className, | ||
)} | ||
{...props} | ||
/> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { cn } from '@/lib/utils' | ||
import { useEffect } from 'react' | ||
import Animated, { | ||
useAnimatedStyle, | ||
useSharedValue, | ||
withRepeat, | ||
withSequence, | ||
withTiming, | ||
} from 'react-native-reanimated' | ||
|
||
const duration = 5000 | ||
|
||
export function ScanningIndicator({ | ||
className, | ||
...props | ||
}: Omit<React.ComponentPropsWithoutRef<typeof Animated.View>, 'style'>) { | ||
const sv = useSharedValue(0) | ||
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | ||
useEffect(() => { | ||
sv.value = withRepeat( | ||
withSequence(withTiming(1, { duration }), withTiming(0, { duration })), | ||
-1, | ||
) | ||
}, []) | ||
|
||
const style = useAnimatedStyle(() => ({ | ||
top: `${sv.value * 65 + 16}%`, | ||
})) | ||
|
||
return ( | ||
<Animated.View | ||
style={[style]} | ||
className={cn('w-[80%] h-0.5 bg-background absolute', className)} | ||
{...props} | ||
/> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { cn } from '@/lib/utils' | ||
import { useEffect } from 'react' | ||
import Animated, { | ||
useAnimatedStyle, | ||
useSharedValue, | ||
withRepeat, | ||
withSequence, | ||
withTiming, | ||
} from 'react-native-reanimated' | ||
|
||
const duration = 1000 | ||
|
||
export function ScanningOverlay({ | ||
className, | ||
...props | ||
}: Omit<React.ComponentPropsWithoutRef<typeof Animated.View>, 'style'>) { | ||
const sv = useSharedValue(1) | ||
|
||
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation> | ||
useEffect(() => { | ||
sv.value = withRepeat( | ||
withSequence(withTiming(0, { duration }), withTiming(1, { duration })), | ||
-1, | ||
) | ||
}, []) | ||
|
||
const style = useAnimatedStyle(() => ({ | ||
opacity: sv.value, | ||
})) | ||
|
||
return ( | ||
<Animated.View | ||
style={[style]} | ||
className={cn( | ||
'flex-1 bg-background/50 absolute top-0 left-0 bottom-0 right-0 pointer-events-none', | ||
className, | ||
)} | ||
{...props} | ||
/> | ||
) | ||
} |
Oops, something went wrong.