From e9bb6a8436ea1451f8b443a4601ba2979703947a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Qu=E1=BB=91c=20Kh=C3=A1nh?= Date: Mon, 15 Jul 2024 22:21:37 +0700 Subject: [PATCH] feat(mobile): [Scanner] add scanner view and integrate with AI api --- apps/mobile/app.json | 16 +- apps/mobile/app/(app)/(tabs)/scanner.tsx | 194 +++++++++++++++++- .../app/(app)/transaction/new-record.tsx | 6 +- .../transaction/transaction-form.tsx | 12 +- .../transaction/transaction-item.tsx | 4 +- apps/mobile/mutations/transaction.ts | 36 +++- apps/mobile/package.json | 4 + packages/validation/src/transaction.zod.ts | 2 +- pnpm-lock.yaml | 51 +++++ 9 files changed, 308 insertions(+), 17 deletions(-) diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 4ae92be7..b92b0db9 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -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 }, diff --git a/apps/mobile/app/(app)/(tabs)/scanner.tsx b/apps/mobile/app/(app)/(tabs)/scanner.tsx index 10b1cf72..6d296c5c 100644 --- a/apps/mobile/app/(app)/(tabs)/scanner.tsx +++ b/apps/mobile/app/(app)/(tabs)/scanner.tsx @@ -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(null) + const router = useRouter() + const [facing, setFacing] = useState('back') + const [permission, requestPermission] = useCameraPermissions() + const [imageUri, setImageUri] = useState(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: + 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 ( + + + + ) + } + + if (!permission.granted) { + // Camera permissions are not granted. + return ( + + + {t(i18n)`Camera permissions are not granted`} + + + ) + } + + if (imageUri) { + return ( + + + + {t(i18n)`Processing transaction...`} + + + + + ) + } + return ( - Scanner Screen - + + + {t(i18n)`Take a picture of your transaction`} + + + + + + + ) } diff --git a/apps/mobile/app/(app)/transaction/new-record.tsx b/apps/mobile/app/(app)/transaction/new-record.tsx index 79ac0f12..e9919602 100644 --- a/apps/mobile/app/(app)/transaction/new-record.tsx +++ b/apps/mobile/app/(app)/transaction/new-record.tsx @@ -2,14 +2,17 @@ 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 { 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 queryClient = useQueryClient() @@ -53,6 +56,7 @@ export default function NewRecordScreen() { defaultValues={{ walletAccountId: defaultWallet.id, currency: defaultWallet.preferredCurrency ?? 'USD', + ...defaultValues, }} /> ) diff --git a/apps/mobile/components/transaction/transaction-form.tsx b/apps/mobile/components/transaction/transaction-form.tsx index 99c2b450..0abfa43b 100644 --- a/apps/mobile/components/transaction/transaction-form.tsx +++ b/apps/mobile/components/transaction/transaction-form.tsx @@ -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} /> @@ -117,11 +119,7 @@ export const TransactionForm = ({ {t(i18n)`Save`} diff --git a/apps/mobile/components/transaction/transaction-item.tsx b/apps/mobile/components/transaction/transaction-item.tsx index fd8adeb5..03fd5ff8 100644 --- a/apps/mobile/components/transaction/transaction-item.tsx +++ b/apps/mobile/components/transaction/transaction-item.tsx @@ -48,7 +48,9 @@ export const TransactionItem: FC = ({ transaction }) => { name={iconName as any} className="size-5 text-foreground" /> - {transactionName} + + {transactionName} + + Authorization: `Bearer ${token}`, + }, + }, + ) + + const body = JSON.parse(result.body) + + const transaction = zUpdateTransaction.parse({ + ...body, + date: body?.datetime, + }) + + return transaction +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d2c99d36..ca6a04da 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", diff --git a/packages/validation/src/transaction.zod.ts b/packages/validation/src/transaction.zod.ts index 2e7fed28..72e9cd0b 100644 --- a/packages/validation/src/transaction.zod.ts +++ b/packages/validation/src/transaction.zod.ts @@ -14,7 +14,7 @@ export type CreateTransaction = z.infer export const zUpdateTransaction = z.object({ date: z.date({ coerce: true }).optional(), - amount: z.number().optional(), + amount: z.number({ coerce: true }).optional(), currency: z.string().optional(), note: z.string().optional(), budgetId: z.string().optional(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1ed2401..daf535fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,18 +183,30 @@ importers: expo-auth-session: specifier: ~5.5.2 version: 5.5.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) + expo-camera: + specifier: ~15.0.13 + version: 15.0.13(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) expo-constants: specifier: ~16.0.2 version: 16.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) expo-crypto: specifier: ~13.0.2 version: 13.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) + expo-file-system: + specifier: ~17.0.1 + version: 17.0.1(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) expo-font: specifier: ~12.0.7 version: 12.0.7(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) expo-haptics: specifier: ~13.0.1 version: 13.0.1(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) + expo-image-manipulator: + specifier: ~12.0.5 + version: 12.0.5(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) + expo-image-picker: + specifier: ~15.0.7 + version: 15.0.7(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) expo-linear-gradient: specifier: ~13.0.2 version: 13.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) @@ -3418,6 +3430,11 @@ packages: expo-auth-session@5.5.2: resolution: {integrity: sha512-fgqrNz9FhCl/kNyU2Vy2AmLWk+X7vmgiGN2KVUgB8yLHl/tPogYLpNOiqFl/pMLMveoKjPpVOVfbz3RTJHJoTg==} + expo-camera@15.0.13: + resolution: {integrity: sha512-EhGk1hkc0dgKqtQIw9SX31cl9t+ffDBfMCma+0qvSBnEkcfDQImrDDHSODknQrq6yNQDT9w3LqH5ZouG0m9pJQ==} + peerDependencies: + expo: '*' + expo-constants@16.0.2: resolution: {integrity: sha512-9tNY3OVO0jfiMzl7ngb6IOyR5VFzNoN5OOazUWoeGfmMqVB5kltTemRvKraK9JRbBKIw+SOYLEmF0sEqgFZ6OQ==} peerDependencies: @@ -3443,6 +3460,21 @@ packages: peerDependencies: expo: '*' + expo-image-loader@4.7.0: + resolution: {integrity: sha512-cx+MxxsAMGl9AiWnQUzrkJMJH4eNOGlu7XkLGnAXSJrRoIiciGaKqzeaD326IyCTV+Z1fXvIliSgNW+DscvD8g==} + peerDependencies: + expo: '*' + + expo-image-manipulator@12.0.5: + resolution: {integrity: sha512-zJ8yINjckYw/yfoSuICt4yJ9xr112+W9e5QVXwK3nCAHr7sv45RQ5sxte0qppf594TPl+UoV6Tjim7WpoKipRQ==} + peerDependencies: + expo: '*' + + expo-image-picker@15.0.7: + resolution: {integrity: sha512-u8qiPZNfDb+ap6PJ8pq2iTO7JKX+ikAUQ0K0c7gXGliKLxoXgDdDmXxz9/6QdICTshJBJlBvI0MwY5NWu7A/uw==} + peerDependencies: + expo: '*' + expo-keep-awake@13.0.2: resolution: {integrity: sha512-kKiwkVg/bY0AJ5q1Pxnm/GvpeB6hbNJhcFsoOWDh2NlpibhCLaHL826KHUM+WsnJRbVRxJ+K9vbPRHEMvFpVyw==} peerDependencies: @@ -10571,6 +10603,11 @@ snapshots: - expo - supports-color + expo-camera@15.0.13(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): + dependencies: + expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)) + invariant: 2.2.4 + expo-constants@16.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): dependencies: '@expo/config': 9.0.2 @@ -10597,6 +10634,20 @@ snapshots: dependencies: expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)) + expo-image-loader@4.7.0(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): + dependencies: + expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)) + + expo-image-manipulator@12.0.5(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): + dependencies: + expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)) + expo-image-loader: 4.7.0(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) + + expo-image-picker@15.0.7(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): + dependencies: + expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7)) + expo-image-loader: 4.7.0(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))) + expo-keep-awake@13.0.2(expo@51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))): dependencies: expo: 51.0.14(@babel/core@7.24.7)(@babel/preset-env@7.24.7(@babel/core@7.24.7))