Skip to content

Commit

Permalink
feat(mobile): [Budget] add new budget form (#142)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 committed Jul 19, 2024
1 parent f0c75d8 commit 3ebdc89
Show file tree
Hide file tree
Showing 19 changed files with 2,236 additions and 24 deletions.
5 changes: 4 additions & 1 deletion apps/mobile/app/(app)/(tabs)/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button'
import { Text } from '@/components/ui/text'
import { useColorScheme } from '@/hooks/useColorScheme'
import { theme } from '@/lib/theme'
import { t } from '@lingui/macro'
Expand Down Expand Up @@ -51,11 +52,13 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => <LandPlotIcon color={color} />,
headerRight: () => (
<Link href="/budget/new-budget" asChild>
<Button size="icon" variant="ghost" className="mr-4">
<Button size="sm" variant="secondary" className="mr-6 h-10">
<PlusIcon className="size-6 text-primary" />
<Text>{t(i18n)`New budget`}</Text>
</Button>
</Link>
),
headerTitleAlign: 'left',
}}
/>
{/* <Tabs.Screen
Expand Down
7 changes: 7 additions & 0 deletions apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ export default function AuthenticatedLayout() {
headerTitle: t(i18n)`Edit category`,
}}
/>
<Stack.Screen
name="budget/new-budget"
options={{
presentation: 'modal',
headerTitle: t(i18n)`New budget`,
}}
/>
</Stack>
)
}
27 changes: 27 additions & 0 deletions apps/mobile/app/(app)/budget/new-budget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { BudgetForm } from '@/components/budget/budget-form'
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'

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

const handleCreate = async (data: BudgetFormValues) => {
mutateAsync({ data, id: createId() }).catch(() => {
// ignore
})
router.back()
}

return (
<View className="bg-card" {...rootProps}>
<BudgetForm onSubmit={handleCreate} sideOffset={sideOffset} />
<PortalHost name="budget-form" />
</View>
)
}
2 changes: 2 additions & 0 deletions apps/mobile/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
DefaultTheme,
ThemeProvider,
} from '@react-navigation/native'
import { PortalHost } from '@rn-primitives/portal'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import { focusManager, onlineManager } from '@tanstack/react-query'
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
Expand Down Expand Up @@ -126,6 +127,7 @@ export default function RootLayout() {
/>
</Stack>
<ToastRoot />
<PortalHost />
</BottomSheetModalProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
Expand Down
160 changes: 160 additions & 0 deletions apps/mobile/components/budget/budget-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {
type BudgetFormValues,
BudgetPeriodTypeSchema,
BudgetTypeSchema,
zBudgetFormValues,
} from '@6pm/validation'
import { zodResolver } from '@hookform/resolvers/zod'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import * as Haptics from 'expo-haptics'
import { useRef } from 'react'
import {
Controller,
FormProvider,
type UseFormReturn,
useForm,
useWatch,
} from 'react-hook-form'
import { ScrollView, View } from 'react-native'
import type { TextInput } from 'react-native'
import { CurrencyField } from '../form-fields/currency-field'
import { InputField } from '../form-fields/input-field'
import { SubmitButton } from '../form-fields/submit-button'
import { Label } from '../ui/label'
import { Text } from '../ui/text'
import { PeriodRangeField } from './period-range-field'
import { SelectBudgetTypeField } from './select-budget-type-field'
import { SelectPeriodTypeField } from './select-period-type-field'

type BudgetFormProps = {
onSubmit: (data: BudgetFormValues) => void
defaultValues?: Partial<BudgetFormValues>
sideOffset?: number
}

function BudgetSubmitButton({
form,
onSubmit,
}: {
form: UseFormReturn<BudgetFormValues>
onSubmit: (data: BudgetFormValues) => void
}) {
const { i18n } = useLingui()
const amount = useWatch({ name: 'period.amount' })

return (
<SubmitButton
onPress={form.handleSubmit(onSubmit)}
onPressIn={Haptics.selectionAsync}
disabled={form.formState.isLoading || !amount}
>
<Text>{t(i18n)`Save`}</Text>
</SubmitButton>
)
}

export const BudgetForm = ({
onSubmit,
defaultValues,
sideOffset,
}: BudgetFormProps) => {
const { i18n } = useLingui()
const nameInputRef = useRef<TextInput>(null)
const amountInputRef = useRef<TextInput>(null)

const budgetForm = useForm<BudgetFormValues>({
resolver: zodResolver(zBudgetFormValues),
defaultValues: {
name: '',
description: '',
preferredCurrency: 'USD',
type: BudgetTypeSchema.Enum.SPENDING,
...defaultValues,
period: {
type: BudgetPeriodTypeSchema.Enum.MONTHLY,
...defaultValues?.period,
},
},
})

return (
<FormProvider {...budgetForm}>
<ScrollView
contentContainerClassName="flex flex-1 gap-4 py-3 px-6"
keyboardDismissMode="on-drag"
keyboardShouldPersistTaps="handled"
automaticallyAdjustKeyboardInsets
>
<Controller
name="type"
control={budgetForm.control}
render={({ field: { onChange, value } }) => (
<View className="gap-1">
<Label nativeID={`label-type`}>{t(i18n)`Type`}</Label>
<SelectBudgetTypeField
value={value}
sideOffset={sideOffset}
onSelect={(type) => {
onChange(type)
nameInputRef.current?.focus()
}}
/>
</View>
)}
/>
<InputField
ref={nameInputRef}
name="name"
label={t(i18n)`Name`}
placeholder={t(i18n)`Family budget`}
disabled={budgetForm.formState.isLoading}
onSubmitEditing={() => amountInputRef.current?.focus()}
/>
<InputField
ref={amountInputRef}
name="period.amount"
label={t(i18n)`Target`}
placeholder={t(i18n)`0.00`}
className="!pl-[62px]"
keyboardType="number-pad"
leftSection={
<Controller
name="preferredCurrency"
control={budgetForm.control}
render={({ field: { onChange, value } }) => (
<CurrencyField
value={value}
onChange={(selected) => {
onChange(selected)
amountInputRef.current?.focus()
}}
/>
)}
/>
}
/>
<Controller
name="period.type"
control={budgetForm.control}
render={({ field: { onChange, value } }) => (
<View className="gap-1">
<Label nativeID={`label-period-type`}>{t(i18n)`Period`}</Label>
<SelectPeriodTypeField
value={value}
sideOffset={sideOffset}
onSelect={(type) => {
onChange(type)
}}
/>
</View>
)}
/>

<PeriodRangeField />

<BudgetSubmitButton form={budgetForm} onSubmit={onSubmit} />
</ScrollView>
</FormProvider>
)
}
42 changes: 42 additions & 0 deletions apps/mobile/components/budget/period-range-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useController, useFormState, useWatch } from 'react-hook-form'
import { View } from 'react-native'
import { DateRangePicker } from '../common/date-range-picker'
import { Text } from '../ui/text'

export function PeriodRangeField() {
const { errors } = useFormState()
const periodType = useWatch({ name: 'period.type' })

const {
field: { onChange: onChangeStartDate, value: startDate },
} = useController({
name: 'period.startDate',
})
const {
field: { onChange: onChangeEndDate, value: endDate },
} = useController({
name: 'period.endDate',
})

if (periodType !== 'CUSTOM') {
return null
}

return (
<View className="gap-2">
<DateRangePicker
value={[startDate, endDate]}
onChange={(dates) => {
const [startDate, endDate] = dates ?? []
onChangeStartDate(startDate)
onChangeEndDate(endDate)
}}
/>
{!!errors.period?.root?.message && (
<Text className="text-destructive text-center">
{errors.period.root.message.toString()}
</Text>
)}
</View>
)
}
102 changes: 102 additions & 0 deletions apps/mobile/components/budget/select-budget-type-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { BudgetTypeSchema } from '@6pm/validation'
import { t } from '@lingui/macro'
import { useLingui } from '@lingui/react'
import { useMemo } from 'react'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import GenericIcon from '../common/generic-icon'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select'

type SelectBudgetTypeFieldProps = {
value: string
onSelect: (type?: string) => void
sideOffset?: number
}

export function SelectBudgetTypeField({
value,
onSelect,
sideOffset,
}: SelectBudgetTypeFieldProps) {
const { i18n } = useLingui()
const insets = useSafeAreaInsets()
const contentInsets = {
top: insets.top,
bottom: insets.bottom + Math.abs(sideOffset || 0),
left: 21,
right: 21,
}

const options = useMemo(
() => [
{
value: BudgetTypeSchema.Enum.SPENDING,
label: t(i18n)`Spending`,
icon: 'HandCoins',
},
{
value: BudgetTypeSchema.Enum.SAVING,
label: t(i18n)`Saving`,
icon: 'PiggyBank',
},
{
value: BudgetTypeSchema.Enum.INVESTING,
label: t(i18n)`Investing`,
icon: 'TrendingUp',
},
{
value: BudgetTypeSchema.Enum.DEBT,
label: t(i18n)`Debt`,
icon: 'Landmark',
},
],
[i18n],
)

return (
<Select
defaultValue={options[0]}
value={options.find((option) => option.value === value)}
onValueChange={(selected) => onSelect(selected?.value)}
>
<SelectTrigger>
<GenericIcon
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
name={options.find((option) => option.value === value)?.icon as any}
className="w-5 h-5 text-foreground absolute left-3"
/>
<SelectValue
className="font-sans text-foreground left-8"
placeholder={t(i18n)`Select budget type`}
>
{value}
</SelectValue>
</SelectTrigger>
<SelectContent
sideOffset={(sideOffset || 0) + 6}
insets={contentInsets}
alignOffset={10}
portalHost="budget-form"
className="w-full"
>
<SelectGroup>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
label={option.label}
>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
Loading

0 comments on commit 3ebdc89

Please sign in to comment.