diff --git a/apps/mobile/app/(app)/(tabs)/index.tsx b/apps/mobile/app/(app)/(tabs)/index.tsx index 60a99205..fbc52fea 100644 --- a/apps/mobile/app/(app)/(tabs)/index.tsx +++ b/apps/mobile/app/(app)/(tabs)/index.tsx @@ -135,11 +135,12 @@ export default function HomeScreen() { )} renderSectionHeader={({ section: { title, sum } }) => ( - - {title} + + {title} {t(i18n)`Get 6pm Pro`} - + {t(i18n)`Unlocks full AI power and more!`} @@ -315,7 +315,7 @@ export default function SettingsScreen() { className="!px-6 justify-start gap-6" > - + {t(i18n)`Sign out`} diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 4eb7a771..ddc25d1a 100644 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -161,6 +161,13 @@ export default function AuthenticatedLayout() { headerTitle: t(i18n)`New budget`, }} /> + ) diff --git a/apps/mobile/app/(app)/blob-viewer.tsx b/apps/mobile/app/(app)/blob-viewer.tsx new file mode 100644 index 00000000..6f2c6507 --- /dev/null +++ b/apps/mobile/app/(app)/blob-viewer.tsx @@ -0,0 +1,18 @@ +import { useLocalSearchParams } from 'expo-router' +import { Image, View } from 'react-native' + +export default function BlobViewerScreen() { + const { blobObjectUrl } = useLocalSearchParams() + if (!blobObjectUrl) { + return null + } + return ( + + + + ) +} diff --git a/apps/mobile/app/(app)/budget/[budgetId]/index.tsx b/apps/mobile/app/(app)/budget/[budgetId]/index.tsx index 4097c211..7b38aacf 100644 --- a/apps/mobile/app/(app)/budget/[budgetId]/index.tsx +++ b/apps/mobile/app/(app)/budget/[budgetId]/index.tsx @@ -251,11 +251,12 @@ export default function BudgetDetailScreen() { )} renderSectionHeader={({ section: { title, sum } }) => ( - - {title} + + {title} diff --git a/apps/mobile/app/(app)/transaction/new-record.tsx b/apps/mobile/app/(app)/transaction/new-record.tsx index dea3e3a4..f28a710e 100644 --- a/apps/mobile/app/(app)/transaction/new-record.tsx +++ b/apps/mobile/app/(app)/transaction/new-record.tsx @@ -20,7 +20,12 @@ import { useLingui } from '@lingui/react' import { createId } from '@paralleldrive/cuid2' import { PortalHost, useModalPortalRoot } from '@rn-primitives/portal' import * as Haptics from 'expo-haptics' -import { useLocalSearchParams, useNavigation, useRouter } from 'expo-router' +import { + Link, + useLocalSearchParams, + useNavigation, + useRouter, +} from 'expo-router' import { CameraIcon, KeyboardIcon, Trash2Icon } from 'lucide-react-native' import { useEffect, useRef, useState } from 'react' import { useForm } from 'react-hook-form' @@ -28,10 +33,12 @@ import { ActivityIndicator, Alert, Dimensions, + Image, Keyboard, ScrollView, View, } from 'react-native' +import { z } from 'zod' const DEFAULT_CATEGORY_CONFIG = { type: 'EXPENSE', @@ -54,7 +61,13 @@ export default function NewRecordScreen() { const { expenseCategories } = useCategoryList() const params = useLocalSearchParams() - const parsedParams = zUpdateTransaction.parse(params) + const { blobObjectUrl, blobObjectId, ...parsedParams } = zUpdateTransaction + .extend({ + blobObjectId: z.string().nullable().optional(), + blobObjectUrl: z.string().nullable().optional(), + }) + .parse(params) + const defaultValues: TransactionFormValues = { date: new Date(), amount: 0, @@ -80,30 +93,45 @@ export default function NewRecordScreen() { // biome-ignore lint/correctness/useExhaustiveDependencies: useEffect(() => { navigation.setOptions({ - headerTitle: () => ( - { - setPage(Number(value)) - Keyboard.dismiss() - ref.current?.scrollTo({ - y: 0, - x: value === '0' ? 0 : width, - animated: true, - }) - }} - className="w-[160px]" - > - - - - - - - - - - ), + headerTitle: () => + blobObjectUrl ? ( + + + + ) : ( + { + setPage(Number(value)) + Keyboard.dismiss() + ref.current?.scrollTo({ + y: 0, + x: value === '0' ? 0 : width, + animated: true, + }) + }} + className="w-[160px]" + > + + + + + + + + + + ), headerRight: () => parsedParams?.id ? ( ) : null, }) - }, [page]) + }, [page, blobObjectUrl]) const handleCreateTransaction = async (values: TransactionFormValues) => { try { @@ -134,6 +162,7 @@ export default function NewRecordScreen() { data: { ...values, amount: values.categoryId ? values.amount : -Math.abs(values.amount), + blobAttachmentIds: blobObjectId ? [blobObjectId] : undefined, }, }) } catch (error) { diff --git a/apps/mobile/components/budget/budget-item.tsx b/apps/mobile/components/budget/budget-item.tsx index 8cabab67..36d8b72f 100644 --- a/apps/mobile/components/budget/budget-item.tsx +++ b/apps/mobile/components/budget/budget-item.tsx @@ -51,10 +51,10 @@ export const BudgetItem: FC = ({ budget }) => { > - + {budget.name} diff --git a/apps/mobile/components/common/bottom-sheet.tsx b/apps/mobile/components/common/bottom-sheet.tsx index 2c575906..092153c3 100644 --- a/apps/mobile/components/common/bottom-sheet.tsx +++ b/apps/mobile/components/common/bottom-sheet.tsx @@ -2,11 +2,13 @@ import { useColorPalette } from '@/hooks/use-color-palette' import { BottomSheetBackdrop, type BottomSheetBackdropProps, + type BottomSheetBackgroundProps, BottomSheetModal, type BottomSheetModalProps, } from '@gorhom/bottom-sheet' import type { BottomSheetModalMethods } from '@gorhom/bottom-sheet/lib/typescript/types' import { forwardRef, useCallback } from 'react' +import { View } from 'react-native' import { FullWindowOverlay } from 'react-native-screens' export const BottomSheet = forwardRef< @@ -34,12 +36,20 @@ export const BottomSheet = forwardRef< [], ) + const backgroundComponent = useCallback( + (props: BottomSheetBackgroundProps) => ( + + ), + [], + ) + return ( diff --git a/apps/mobile/components/form-fields/currency-field.tsx b/apps/mobile/components/form-fields/currency-field.tsx index a1302c28..e753854d 100644 --- a/apps/mobile/components/form-fields/currency-field.tsx +++ b/apps/mobile/components/form-fields/currency-field.tsx @@ -30,7 +30,7 @@ export function CurrencyField({ className, )} > - {value} + {value} )} - {item.name} - {item.percentage}% + {item.name} + {item.percentage}% ) }} diff --git a/apps/mobile/components/home/header.tsx b/apps/mobile/components/home/header.tsx index 6d20517e..8ffa8d0e 100644 --- a/apps/mobile/components/home/header.tsx +++ b/apps/mobile/components/home/header.tsx @@ -1,7 +1,8 @@ import { useUser } from '@clerk/clerk-expo' import { useRouter } from 'expo-router' -import { Text, TouchableOpacity, View } from 'react-native' +import { TouchableOpacity, View } from 'react-native' import { UserAvatar } from '../common/user-avatar' +import { Text } from '../ui/text' import { type HomeFilter, SelectFilter } from './select-filter' import { SelectWalletAccount } from './select-wallet-account' @@ -31,7 +32,7 @@ export function HomeHeader({ - + {user?.fullName ?? user?.primaryEmailAddress?.emailAddress} {value} diff --git a/apps/mobile/components/home/wallet-statistics.tsx b/apps/mobile/components/home/wallet-statistics.tsx index 03373629..3c6db1e0 100644 --- a/apps/mobile/components/home/wallet-statistics.tsx +++ b/apps/mobile/components/home/wallet-statistics.tsx @@ -128,7 +128,7 @@ export function WalletStatistics({ className="!border-0 h-auto native:h-auto flex-col items-center gap-3" > - + {options.find((option) => option.value === view)?.label} diff --git a/apps/mobile/components/transaction/draft-transaction-item.tsx b/apps/mobile/components/transaction/draft-transaction-item.tsx index 2a5962a4..4e30fedc 100644 --- a/apps/mobile/components/transaction/draft-transaction-item.tsx +++ b/apps/mobile/components/transaction/draft-transaction-item.tsx @@ -6,7 +6,7 @@ import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import { useMutation, useMutationState } from '@tanstack/react-query' import { Link } from 'expo-router' -import { CircleAlert } from 'lucide-react-native' +import { CircleAlert, SparklesIcon } from 'lucide-react-native' import type { FC } from 'react' import { ActivityIndicator, Alert, Image, Pressable, View } from 'react-native' import { AmountFormat } from '../common/amount-format' @@ -89,7 +89,7 @@ export const DraftTransactionItem: FC = ({ if (latestState?.state?.status === 'pending') { return ( { Alert.alert('', t(i18n)`Transaction is processing...`, [ { @@ -106,16 +106,28 @@ export const DraftTransactionItem: FC = ({ ]) }} > - - + + + + - {t(i18n)`Processing...`} + {t(i18n)`AI Processing...`} - + e.stopPropagation()} + > + + ) } @@ -124,19 +136,31 @@ export const DraftTransactionItem: FC = ({ return ( - - + + + + {latestState?.state?.failureReason?.message ?? t(i18n)`Cannot process image`} - + e.stopPropagation()} + > + + ) } @@ -147,26 +171,86 @@ export const DraftTransactionItem: FC = ({ push href={{ pathname: '/transaction/new-record', - // biome-ignore lint/suspicious/noExplicitAny: - params: transaction as any, + params: { + ...transaction, + blobObjectUrl: transaction.blobObject?.url, + blobObjectId: transaction.blobObject?.id, + // biome-ignore lint/suspicious/noExplicitAny: + } as any, }} > - - + + name={iconName as any} - className="size-5 text-foreground" + className="size-6 text-secondary-foreground" /> - - {transactionName} - + {transaction.blobObject && ( + + + + )} + + + + + {transactionName} + + + + + + + + + {t(i18n)`Processed by AI`} + + + {/* {transaction.walletAccount && ( + + + name={transaction.walletAccount.icon as any} + className="size-4 text-muted-foreground" + /> + + {transaction.walletAccount.name} + + + )} + {transaction.budget && ( + + + + {transaction.budget.name} + + + )} */} + - ) diff --git a/apps/mobile/components/transaction/draft-transaction-list.tsx b/apps/mobile/components/transaction/draft-transaction-list.tsx index 2b85e5a4..cbe44f3f 100644 --- a/apps/mobile/components/transaction/draft-transaction-list.tsx +++ b/apps/mobile/components/transaction/draft-transaction-list.tsx @@ -17,9 +17,7 @@ export function DraftTransactionList() { return ( - {t( - i18n, - )`Waiting for review`} + {t(i18n)`Waiting for review`} {draftTransactions.length} diff --git a/apps/mobile/components/transaction/scanner.tsx b/apps/mobile/components/transaction/scanner.tsx index 896303f8..52e4b138 100644 --- a/apps/mobile/components/transaction/scanner.tsx +++ b/apps/mobile/components/transaction/scanner.tsx @@ -60,8 +60,10 @@ export function Scanner({ useTransactionStore() const { entitlement } = useUserEntitlements() - const todayTransactions = transactions.filter((t) => - dayjsExtended(t.createdAt).isSame(dayjsExtended(), 'day'), + const todayTransactions = transactions.filter( + (t) => + !!t.blobAttachments?.length && // TODO: Better way to check if transaction is from AI + dayjsExtended(t.createdAt).isSame(dayjsExtended(), 'day'), ) const { mutateAsync } = useMutation({ @@ -95,7 +97,7 @@ export function Scanner({ todayTransactions.length async function processImages(uris: string[]) { - if (transactionQuota - uris.length <= 0) { + if (transactionQuota - uris.length < 0) { onLimitExceeded?.() return } diff --git a/apps/mobile/components/transaction/select-account-field.tsx b/apps/mobile/components/transaction/select-account-field.tsx index da1cd373..3975c62e 100644 --- a/apps/mobile/components/transaction/select-account-field.tsx +++ b/apps/mobile/components/transaction/select-account-field.tsx @@ -14,6 +14,7 @@ import { Keyboard, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { BottomSheet } from '../common/bottom-sheet' import GenericIcon from '../common/generic-icon' +import { Badge } from '../ui/badge' import { Button } from '../ui/button' import { Text } from '../ui/text' @@ -68,13 +69,15 @@ export function SelectAccountField({ keyboardShouldPersistTaps="handled" keyboardDismissMode="on-drag" ListHeaderComponent={ - - {t(i18n)`Wallet Accounts`} - + + {t( + i18n, + )`Wallet Accounts`} + } contentContainerStyle={{ paddingBottom: bottom + 16 }} renderItem={({ item }) => ( - + diff --git a/apps/mobile/components/transaction/select-category-field.tsx b/apps/mobile/components/transaction/select-category-field.tsx index 47d309ae..d82982be 100644 --- a/apps/mobile/components/transaction/select-category-field.tsx +++ b/apps/mobile/components/transaction/select-category-field.tsx @@ -141,7 +141,7 @@ export function SelectCategoryField({ bounce loop animationType="bounce" - className="!text-base line-clamp-1 text-center text-muted-foreground" + className="line-clamp-1 text-center font-regular text-muted-foreground" > {item.name} diff --git a/apps/mobile/components/transaction/transaction-form.tsx b/apps/mobile/components/transaction/transaction-form.tsx index 8b468ce8..faf57e00 100644 --- a/apps/mobile/components/transaction/transaction-form.tsx +++ b/apps/mobile/components/transaction/transaction-form.tsx @@ -1,11 +1,11 @@ import { useUserEntitlements } from '@/hooks/use-purchases' import { sleep } from '@/lib/utils' -import type { TransactionFormValues } from '@6pm/validation' +import type { BlobObject, TransactionFormValues } from '@6pm/validation' import type { BottomSheetModal } from '@gorhom/bottom-sheet' import { t } from '@lingui/macro' import { useLingui } from '@lingui/react' import * as Haptics from 'expo-haptics' -import { useRouter } from 'expo-router' +import { Link, useRouter } from 'expo-router' import { Trash2Icon } from 'lucide-react-native' import { useRef } from 'react' import { @@ -16,6 +16,7 @@ import { useWatch, } from 'react-hook-form' import { ScrollView, View } from 'react-native' +import { Image } from 'react-native' import Animated, { useAnimatedKeyboard, useAnimatedStyle, @@ -40,6 +41,7 @@ type TransactionFormProps = { onOpenScanner?: () => void form: UseFormReturn sideOffset?: number + blobAttachments?: BlobObject[] | null } export function TransactionAmount() { @@ -109,6 +111,7 @@ export const TransactionForm = ({ onDelete, // onOpenScanner, sideOffset, + blobAttachments, }: TransactionFormProps) => { const { i18n } = useLingui() @@ -132,18 +135,37 @@ export const TransactionForm = ({ {/* */} - ( - + + ( + + )} + /> + {!!blobAttachments?.length && ( + + + )} - /> + {onDelete ? (