Skip to content

Commit

Permalink
feat(mobile): [Scanner] add scanner view and integrate with AI api
Browse files Browse the repository at this point in the history
  • Loading branch information
Quốc Khánh committed Jul 16, 2024
1 parent 3f97df8 commit 7fd4631
Show file tree
Hide file tree
Showing 17 changed files with 529 additions and 75 deletions.
16 changes: 15 additions & 1 deletion apps/mobile/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,21 @@
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": ["expo-router"],
"plugins": [
"expo-router",
[
"expo-camera",
{
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera for scanning invoices and transactions"
}
],
[
"expo-image-picker",
{
"photosPermission": "Allow $(PRODUCT_NAME) accesses your photos for scanning invoices and transactions"
}
]
],
"experiments": {
"typedRoutes": true
},
Expand Down
185 changes: 181 additions & 4 deletions apps/mobile/app/(app)/(tabs)/scanner.tsx
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>
)
}
13 changes: 10 additions & 3 deletions apps/mobile/app/(app)/transaction/new-record.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { toast } from '@/components/common/toast'
import { TransactionForm } from '@/components/transaction/transaction-form'
import { createTransaction } from '@/mutations/transaction'
import { transactionQueries } from '@/queries/transaction'
import { useWallets, walletQueries } from '@/queries/wallet'
import { zUpdateTransaction } from '@6pm/validation'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import * as Haptics from 'expo-haptics'
import { useRouter } from 'expo-router'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { LoaderIcon } from 'lucide-react-native'
import { Alert, View } from 'react-native'

export default function NewRecordScreen() {
const router = useRouter()
const params = useLocalSearchParams()
const defaultValues = zUpdateTransaction.parse(params)
const { data: walletAccounts } = useWallets()
// const { i18n } = useLingui()
const { i18n } = useLingui()
const queryClient = useQueryClient()
const { mutateAsync } = useMutation({
mutationFn: createTransaction,
Expand All @@ -22,7 +28,7 @@ export default function NewRecordScreen() {
onSuccess() {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
router.back()
// toast.success(t(i18n)`Transaction created`)
toast.success(t(i18n)`Transaction created`)
},
async onSettled() {
await Promise.all([
Expand Down Expand Up @@ -53,6 +59,7 @@ export default function NewRecordScreen() {
defaultValues={{
walletAccountId: defaultWallet.id,
currency: defaultWallet.preferredCurrency ?? 'USD',
...defaultValues,
}}
/>
)
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/components/common/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Toasts, toast as rntoast } from '@backpackapp-io/react-native-toast'
export function ToastRoot() {
return (
<Toasts
extraInsets={{ top: -12 }}
defaultStyle={{
text: {
fontFamily: 'Be Vietnam Pro',
Expand Down
41 changes: 41 additions & 0 deletions apps/mobile/components/scanner/animated-ring.tsx
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}
/>
)
}
38 changes: 38 additions & 0 deletions apps/mobile/components/scanner/scanning-indicator.tsx
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}
/>
)
}
41 changes: 41 additions & 0 deletions apps/mobile/components/scanner/scanning-overlay.tsx
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}
/>
)
}
Loading

0 comments on commit 7fd4631

Please sign in to comment.