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 15, 2024
1 parent 3f97df8 commit 50faf48
Show file tree
Hide file tree
Showing 7 changed files with 302 additions and 15 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
194 changes: 190 additions & 4 deletions apps/mobile/app/(app)/(tabs)/scanner.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,197 @@
import { Toolbar } from '@/components/common/toolbar'
import { Text, View } from 'react-native'
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)
setImageUri(null)
},
onSuccess(result) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
// router.push()
if (result.amount) {
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 takePicture() {
Haptics.selectionAsync()
const result = await camera.current?.takePictureAsync({
scale: 0.5,
quality: 0.5,
})
if (result?.uri) {
const manipResult = await manipulateAsync(
result.uri,
[
{
resize: { width: 1024 },
},
],
{
compress: 0.5,
format: SaveFormat.WEBP,
},
)
setImageUri(manipResult.uri)
await mutateAsync(manipResult.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
}
const manipResult = await manipulateAsync(
result.assets[0].uri,
[
{
resize: { width: 1024 },
},
],
{
compress: 0.5,
format: SaveFormat.WEBP,
},
)
setImageUri(manipResult.uri)
await mutateAsync(manipResult.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-card">
<ImageBackground
source={{ uri: imageUri }}
className="flex-1 items-center"
>
<View className="top-6 bg-background/50 p-2 px-4 rounded-md">
<Text>{t(i18n)`Processing transaction...`}</Text>
</View>
<Button
variant="secondary"
size="icon"
className="w-auto h-auto p-1 absolute bottom-6 rounded-full bg-primary-foreground"
disabled
>
<View className="w-16 h-16 bg-primary-foreground border-2 border-primary justify-center items-center rounded-full">
<LoaderIcon className="size-7 animate-spin text-primary" />
</View>
</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>
)
}
4 changes: 3 additions & 1 deletion apps/mobile/app/(app)/transaction/new-record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { transactionQueries } from '@/queries/transaction'
import { useWallets, walletQueries } from '@/queries/wallet'
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 { data: walletAccounts } = useWallets()
// const { i18n } = useLingui()
const queryClient = useQueryClient()
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function NewRecordScreen() {
defaultValues={{
walletAccountId: defaultWallet.id,
currency: defaultWallet.preferredCurrency ?? 'USD',
...params,
}}
/>
)
Expand Down
12 changes: 5 additions & 7 deletions apps/mobile/components/transaction/transaction-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ export const TransactionForm = ({
name="note"
placeholder={t(i18n)`transaction note`}
autoCapitalize="none"
className="truncate line-clamp-1 bg-transparent border-0"
className="truncate line-clamp-1 bg-transparent h-8 border-0"
placeholderClassName="!text-muted"
wrapperClassName="absolute left-4 right-4 bottom-2"
wrapperClassName="absolute left-4 right-4 bottom-4"
numberOfLines={1}
multiline={false}
/>
</View>
<Animated.View style={translateStyle}>
Expand All @@ -117,11 +119,7 @@ export const TransactionForm = ({
</View>
<SubmitButton
onPress={transactionForm.handleSubmit(onSubmit)}
disabled={
transactionForm.formState.isLoading ||
!amount ||
!transactionForm.formState.isDirty
}
disabled={transactionForm.formState.isLoading || !amount}
>
<Text>{t(i18n)`Save`}</Text>
</SubmitButton>
Expand Down
36 changes: 34 additions & 2 deletions apps/mobile/mutations/transaction.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { getHonoClient } from '@/lib/client'
import { type TransactionFormValues, TransactionSchema } from '@6pm/validation'
import { clerk, getHonoClient } from '@/lib/client'
import {
type TransactionFormValues,
TransactionSchema,
zUpdateTransaction,
} from '@6pm/validation'
import * as FileSystem from 'expo-file-system'
import { z } from 'zod'

export async function createTransaction(data: TransactionFormValues) {
Expand Down Expand Up @@ -59,3 +64,30 @@ export async function deleteTransaction(id: string) {
param: { transactionId: id },
})
}

export async function getAITransactionData(fileUri: string) {
const token = await clerk.session?.getToken()

const result = await FileSystem.uploadAsync(
`${process.env.EXPO_PUBLIC_API_URL!}v1/transactions/ai`,
fileUri,
{
fieldName: 'file',
httpMethod: 'POST',
uploadType: FileSystem.FileSystemUploadType.MULTIPART,
headers: {
// biome-ignore lint/style/useNamingConvention: <explanation>
Authorization: `Bearer ${token}`,
},
},
)

const body = JSON.parse(result.body)

const transaction = zUpdateTransaction.parse({
...body,
date: body?.datetime,
})

return transaction
}
4 changes: 4 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@
"date-fns": "^3.6.0",
"expo": "~51.0.11",
"expo-auth-session": "~5.5.2",
"expo-camera": "~15.0.13",
"expo-constants": "~16.0.2",
"expo-crypto": "~13.0.2",
"expo-file-system": "~17.0.1",
"expo-font": "~12.0.7",
"expo-haptics": "~13.0.1",
"expo-image-manipulator": "~12.0.5",
"expo-image-picker": "~15.0.7",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
"expo-localization": "^15.0.3",
Expand Down
Loading

0 comments on commit 50faf48

Please sign in to comment.