Skip to content

Commit

Permalink
feat(mobile): add user default budget (#248)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 committed Sep 1, 2024
1 parent 2df82a7 commit 7b7d87f
Show file tree
Hide file tree
Showing 17 changed files with 230 additions and 72 deletions.
6 changes: 4 additions & 2 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { BackButton } from '@/components/common/back-button'
import { Button } from '@/components/ui/button'
import { useLocalAuth } from '@/hooks/use-local-auth'
import { useScheduleNotificationTrigger } from '@/hooks/use-schedule-notification'
import { useUserMetadata } from '@/hooks/use-user-metadata'
import { useColorScheme } from '@/hooks/useColorScheme'
import { theme } from '@/lib/theme'
import { useUser } from '@clerk/clerk-expo'
Expand All @@ -13,7 +14,8 @@ import { PlusIcon } from 'lucide-react-native'
import { useEffect } from 'react'

export default function AuthenticatedLayout() {
const { user, isLoaded, isSignedIn } = useUser()
const { isLoaded, isSignedIn } = useUser()
const { onboardedAt } = useUserMetadata()
const { colorScheme } = useColorScheme()
const { i18n } = useLingui()
const { shouldAuthLocal, setShouldAuthLocal } = useLocalAuth()
Expand All @@ -29,7 +31,7 @@ export default function AuthenticatedLayout() {
return <Redirect href={'/login'} />
}

if (!user?.unsafeMetadata?.onboardedAt && isLoaded) {
if (!onboardedAt && isLoaded) {
return <Redirect href={'/onboarding/step-one'} />
}

Expand Down
8 changes: 7 additions & 1 deletion apps/mobile/app/(app)/budget/[budgetId]/edit.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { BudgetForm } from '@/components/budget/budget-form'
import { Button } from '@/components/ui/button'
import { useUserMetadata } from '@/hooks/use-user-metadata'
import {
useBudget,
useDeleteBudget,
Expand All @@ -23,6 +24,7 @@ export default function EditBudgetScreen() {
const { budget } = useBudget(budgetId!)
const { mutateAsync } = useUpdateBudget()
const { mutateAsync: mutateDelete } = useDeleteBudget()
const { setDefaultBudgetId, defaultBudgetId } = useUserMetadata()
const { sideOffset, ...rootProps } = useModalPortalRoot()

useEffect(() => {
Expand Down Expand Up @@ -65,7 +67,10 @@ export default function EditBudgetScreen() {
orderBy(budget?.periodConfigs, 'startDate', 'desc'),
)

const handleUpdate = async (data: BudgetFormValues) => {
const handleUpdate = async ({ isDefault, ...data }: BudgetFormValues) => {
if (isDefault) {
await setDefaultBudgetId(budget?.id)
}
mutateAsync({
data: data,
id: budget?.id!,
Expand Down Expand Up @@ -95,6 +100,7 @@ export default function EditBudgetScreen() {
startDate: latestPeriodConfig?.startDate ?? undefined,
endDate: latestPeriodConfig?.endDate ?? undefined,
},
isDefault: defaultBudgetId === budget?.id,
}}
/>
<PortalHost name="budget-form" />
Expand Down
12 changes: 10 additions & 2 deletions apps/mobile/app/(app)/budget/new-budget.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { BudgetForm } from '@/components/budget/budget-form'
import { useUserMetadata } from '@/hooks/use-user-metadata'
import { useCreateBudget } from '@/stores/budget/hooks'
import type { BudgetFormValues } from '@6pm/validation'
import { createId } from '@paralleldrive/cuid2'
import { PortalHost, useModalPortalRoot } from '@rn-primitives/portal'
import { useRouter } from 'expo-router'
import { View } from 'react-native'

const budgetId = createId()

export default function CreateBudgetScreen() {
const router = useRouter()
const { mutateAsync } = useCreateBudget()
const { sideOffset, ...rootProps } = useModalPortalRoot()
const { setDefaultBudgetId } = useUserMetadata()

const handleCreate = async ({ isDefault, ...data }: BudgetFormValues) => {
if (isDefault) {
await setDefaultBudgetId(budgetId)
}

const handleCreate = async (data: BudgetFormValues) => {
mutateAsync({
data: {
...data,
Expand All @@ -20,7 +28,7 @@ export default function CreateBudgetScreen() {
id: createId(),
},
},
id: createId(),
id: budgetId,
}).catch(() => {
// ignore
})
Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/app/(app)/transaction/new-record.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { toast } from '@/components/common/toast'
import { Scanner } from '@/components/transaction/scanner'
import { TransactionForm } from '@/components/transaction/transaction-form'
import { useUserMetadata } from '@/hooks/use-user-metadata'
import { useWallets, walletQueries } from '@/queries/wallet'
import { useCreateTransaction } from '@/stores/transaction/hooks'
import { useDefaultCurrency } from '@/stores/user-settings/hooks'
Expand Down Expand Up @@ -32,6 +33,7 @@ export default function NewRecordScreen() {
const defaultWallet = walletAccounts?.[0]
const { sideOffset, ...rootProps } = useModalPortalRoot()
const [page, setPage] = useState<number>(0)
const { defaultBudgetId } = useUserMetadata()

const params = useLocalSearchParams()
const parsedParams = zUpdateTransaction.parse(params)
Expand All @@ -41,6 +43,7 @@ export default function NewRecordScreen() {
currency: defaultCurrency,
note: '',
walletAccountId: defaultWallet?.id,
budgetId: defaultBudgetId as string,
...parsedParams,
}

Expand Down
11 changes: 4 additions & 7 deletions apps/mobile/app/onboarding/step-two.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import { SubmitButton } from '@/components/form-fields/submit-button'
import { NumericPad } from '@/components/numeric-pad'
import { TransactionAmount } from '@/components/transaction/transaction-form'
import { Text } from '@/components/ui/text'
import { useUserMetadata } from '@/hooks/use-user-metadata'
import { useCreateBudget } from '@/stores/budget/hooks'
import { useDefaultCurrency } from '@/stores/user-settings/hooks'
import { BudgetPeriodTypeSchema, BudgetTypeSchema } from '@6pm/validation'
import { useUser } from '@clerk/clerk-expo'
import { zodResolver } from '@hookform/resolvers/zod'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
Expand Down Expand Up @@ -59,7 +59,7 @@ export default function StepTwoScreen() {
const { i18n } = useLingui()
const defaultCurrency = useDefaultCurrency()
const { mutateAsync } = useCreateBudget()
const { user } = useUser()
const { setOnboardedAt, setDefaultBudgetId } = useUserMetadata()
const router = useRouter()

const form = useForm<OnboardBudgetFormValues>({
Expand Down Expand Up @@ -88,11 +88,8 @@ export default function StepTwoScreen() {
// ignore
})

await user?.update({
unsafeMetadata: {
onboardedAt: new Date().toISOString(),
},
})
await setDefaultBudgetId(budgetId)
await setOnboardedAt(new Date().toISOString())

const { status: existingStatus } = await Notifications.getPermissionsAsync()

Expand Down
3 changes: 3 additions & 0 deletions apps/mobile/components/budget/budget-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from 'react-hook-form'
import { ScrollView } from 'react-native'
import type { TextInput } from 'react-native'
import { BooleanField } from '../form-fields/boolean-field'
import { CurrencyField } from '../form-fields/currency-field'
import { InputField } from '../form-fields/input-field'
import { SubmitButton } from '../form-fields/submit-button'
Expand Down Expand Up @@ -157,6 +158,8 @@ export const BudgetForm = ({

<PeriodRangeField />

<BooleanField name="isDefault" label={t(i18n)`Set as default`} />

<BudgetSubmitButton form={budgetForm} onSubmit={onSubmit} />
</ScrollView>
</FormProvider>
Expand Down
13 changes: 11 additions & 2 deletions apps/mobile/components/budget/budget-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Link } from 'expo-router'
import type { FC } from 'react'
import { Pressable, View } from 'react-native'

import { useUserMetadata } from '@/hooks/use-user-metadata'
import {
getLatestPeriodConfig,
useBudgetPeriodStats,
Expand All @@ -25,6 +26,7 @@ type BudgetItemProps = {
export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
const { i18n } = useLingui()
const { user } = useUser()
const { defaultBudgetId } = useUserMetadata()

const latestPeriodConfig = getLatestPeriodConfig(budget.periodConfigs)

Expand All @@ -36,6 +38,8 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
isExceeded,
} = useBudgetPeriodStats(latestPeriodConfig!)

const isDefault = defaultBudgetId === budget.id

return (
<Link
asChild
Expand All @@ -47,7 +51,7 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
>
<Pressable className="mx-6 mt-1 mb-3 justify-between gap-4 rounded-lg border border-border p-4">
<View className="flex-row items-center justify-between gap-6">
<View className="gap-2">
<View className="flex-1 gap-2">
<Text
numberOfLines={1}
className="line-clamp-1 flex-1 font-semibold text-lg"
Expand All @@ -60,7 +64,12 @@ export const BudgetItem: FC<BudgetItemProps> = ({ budget }) => {
className="h-7 w-7"
fallbackLabelClassName="text-[10px]"
/>
<Badge variant="outline" className="rounded-full">
{isDefault && (
<Badge variant="secondary">
<Text className="text-sm capitalize">{t(i18n)`Default`}</Text>
</Badge>
)}
<Badge variant="outline">
<Text className="text-sm capitalize">
{latestPeriodConfig?.type}
</Text>
Expand Down
52 changes: 52 additions & 0 deletions apps/mobile/components/form-fields/boolean-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { cn } from '@/lib/utils'
import { useController } from 'react-hook-form'
import { Text, View } from 'react-native'
import { Label } from '../ui/label'
import { Switch } from '../ui/switch'

type BooleanFieldProps = {
name: string
label?: string
disabled?: boolean
wrapperClassName?: string
className?: string
}

export const BooleanField = ({
name,
label,
className,
wrapperClassName,
disabled,
}: BooleanFieldProps) => {
const {
field: { onChange, value },
fieldState,
} = useController({ name })
return (
<View className={cn('gap-1', wrapperClassName)}>
<View className="flex-row items-center gap-4">
{!!label && (
<Label
nativeID={`label-${name}`}
onPress={() => {
onChange(!value)
}}
>
{label}
</Label>
)}
<Switch
className={cn(className)}
checked={value}
disabled={disabled}
onCheckedChange={onChange}
nativeID={`label-${name}`}
/>
</View>
{!!fieldState.error && (
<Text className="text-destructive">{fieldState.error.message}</Text>
)}
</View>
)
}
16 changes: 8 additions & 8 deletions apps/mobile/components/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as SwitchPrimitives from '@/components/primitives/switch'
import { useColorScheme } from '@/hooks/useColorScheme'
import { cn } from '@/lib/utils'
import * as SwitchPrimitives from '@rn-primitives/switch'
import * as React from 'react'
import { Platform } from 'react-native'
import Animated, {
Expand All @@ -8,16 +10,13 @@ import Animated, {
withTiming,
} from 'react-native-reanimated'

import { useColorScheme } from '@/hooks/useColorScheme'
import { cn } from '@/lib/utils'

const SwitchWeb = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer h-6 w-11 shrink-0 cursor-pointer flex-row items-center rounded-full border-2 border-transparent transition-colors disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'peer h-6 w-11 shrink-0 cursor-pointer flex-row items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed',
props.checked ? 'bg-primary' : 'bg-input',
props.disabled && 'opacity-50',
className,
Expand Down Expand Up @@ -71,13 +70,14 @@ const SwitchNative = React.forwardRef<
<Animated.View
style={animatedRootStyle}
className={cn(
'h-7 w-[42px] rounded-full',
'h-8 w-[46px] rounded-full',
props.disabled && 'opacity-50',
)}
>
<SwitchPrimitives.Root
className={cn(
'h-7 w-[42px] shrink-0 flex-row items-center rounded-full border-2 border-transparent',
'h-8 w-[46px] shrink-0 flex-row items-center rounded-full border-2 border-transparent',
props.checked ? 'bg-primary' : 'bg-input',
className,
)}
{...props}
Expand All @@ -86,7 +86,7 @@ const SwitchNative = React.forwardRef<
<Animated.View style={animatedThumbStyle}>
<SwitchPrimitives.Thumb
className={
'h-6 w-6 rounded-full bg-background shadow-foreground/25 shadow-md ring-0'
'h-7 w-7 rounded-full bg-background shadow-foreground/25 shadow-md ring-0'
}
/>
</Animated.View>
Expand Down
30 changes: 30 additions & 0 deletions apps/mobile/hooks/use-user-metadata.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useUser } from '@clerk/clerk-expo'

export function useUserMetadata() {
const { user } = useUser()

async function setOnboardedAt(onboardedAt: string | undefined) {
return user?.update({
unsafeMetadata: {
...(user?.unsafeMetadata ?? {}),
onboardedAt,
},
})
}

async function setDefaultBudgetId(defaultBudgetId: string | undefined) {
return user?.update({
unsafeMetadata: {
...(user?.unsafeMetadata ?? {}),
defaultBudgetId,
},
})
}

return {
onboardedAt: user?.unsafeMetadata?.onboardedAt,
defaultBudgetId: user?.unsafeMetadata?.defaultBudgetId,
setOnboardedAt,
setDefaultBudgetId,
}
}
Loading

0 comments on commit 7b7d87f

Please sign in to comment.