Skip to content

Commit

Permalink
feat(mobile): [Transaction] add basic transaction amount input (#93)
Browse files Browse the repository at this point in the history
  • Loading branch information
bkdev98 committed Jul 11, 2024
1 parent 595c7b3 commit af42cec
Show file tree
Hide file tree
Showing 6 changed files with 235 additions and 3 deletions.
2 changes: 1 addition & 1 deletion apps/mobile/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export default function AuthenticatedLayout() {
<Stack.Screen
name="category/[categoryId]"
options={{
// presentation: 'modal',
presentation: 'modal',
headerTitle: t(i18n)`Edit category`,
}}
/>
Expand Down
20 changes: 18 additions & 2 deletions apps/mobile/app/(app)/new-record.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
import { Text } from 'react-native'
import { NumericPad } from '@/components/numeric-pad'
import { TextTicker } from '@/components/text-ticker'
import { useState } from 'react'
import { View } from 'react-native'

export default function NewRecordScreen() {
return <Text className="font-sans m-4 mx-auto">New Record</Text>
const [value, setValue] = useState<number>(0)
return (
<View className="flex-1 justify-between bg-muted">
<View className="flex-1 items-center justify-center">
<TextTicker
value={value}
className="font-semibold text-6xl leading-tight text-center"
suffix="VND"
suffixClassName="font-semibold ml-2 text-muted-foreground overflow-visible"
/>
</View>
<NumericPad value={value} onValueChange={setValue} />
</View>
)
}
1 change: 1 addition & 0 deletions apps/mobile/components/numeric-pad/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './numeric-pad'
85 changes: 85 additions & 0 deletions apps/mobile/components/numeric-pad/numeric-pad.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { cn } from '@/lib/utils'
import { DeleteIcon } from 'lucide-react-native'
import { View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { Button } from '../ui/button'
import { Text } from '../ui/text'

const buttonKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '000', '0']

type NumericPadProps = {
disabled?: boolean
value: number
onValueChange?: (value: number) => void
maxValue?: number
className?: string
}

export function NumericPad({
disabled,
value = 0,
onValueChange,
maxValue = 9999999999,
className,
}: NumericPadProps) {
const { bottom } = useSafeAreaInsets()

function handleKeyPress(key: string) {
let newValue: number

if (key === '000') {
newValue = value * 1000
} else {
newValue = value * 10 + Number(key)
}

if (newValue > maxValue) {
return
}

onValueChange?.(newValue)
}

function handleDelete() {
const newValue = Math.floor(value / 10)
onValueChange?.(newValue)
}

function handleClear() {
onValueChange?.(0)
}

return (
<View
className={cn(
'flex-wrap bg-card flex-row border-t border-border items-center content-center p-2',
className,
)}
style={{ paddingBottom: bottom }}
>
{buttonKeys.map((buttonKey) => (
<View key={buttonKey} className="w-[33.33%] p-2">
<Button
disabled={disabled}
onPress={() => handleKeyPress(buttonKey)}
variant="ghost"
size="lg"
>
<Text className="!text-2xl">{buttonKey}</Text>
</Button>
</View>
))}
<View className="w-[33.33%] p-2">
<Button
disabled={disabled}
onPress={handleDelete}
onLongPress={handleClear}
variant="secondary"
size="lg"
>
<DeleteIcon className="size-8 text-primary" />
</Button>
</View>
</View>
)
}
1 change: 1 addition & 0 deletions apps/mobile/components/text-ticker/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './text-ticker'
129 changes: 129 additions & 0 deletions apps/mobile/components/text-ticker/text-ticker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { cn } from '@/lib/utils'
import React, { useState } from 'react'
import type { TextStyle } from 'react-native'
import Animated, {
LinearTransition,
SlideInDown,
SlideOutDown,
} from 'react-native-reanimated'
import { Text } from '../ui/text'

function formatNumberWithCommas(formatter: Intl.NumberFormat, num: number) {
const formattedNum = formatter.format(num)
const result: { value: string; key: string }[] = []
let commaCount = 0

for (let i = 0; i < formattedNum.length; i++) {
const char = formattedNum[i]
// We want to count the number of commas because we would like to
// keep the index of the digits the same.
if (char === ',') {
result.push({ value: char, key: `comma-${i}` })

commaCount++
} else {
result.push({ value: char, key: `digit-${i - commaCount}` })
}
}

return result
}

type TextTickerProps = {
style?: TextStyle
className?: string
onChangeText?: (text: string) => void
value: string | number
formatter?: Intl.NumberFormat
autoFocus?: boolean
suffix?: string
suffixClassName?: string
}

export function TextTicker({
style,
className,
value = '0',
formatter = new Intl.NumberFormat('en-US'),
suffix,
suffixClassName,
}: TextTickerProps) {
const initialFontSize = style?.fontSize ?? 68
const animationDuration = 300
const [fontSize, setFontSize] = useState(initialFontSize)

const formattedNumbers = React.useMemo(() => {
return formatNumberWithCommas(formatter, parseFloat(String(value) || '0'))
}, [value, formatter])

return (
<Animated.View
style={{
height: fontSize * 1.2,
}}
className="w-full"
>
{/* Using a dummy Text to let React Native do the math for the font size,
in case the text will not fit on a single line. */}
<Text
numberOfLines={1}
adjustsFontSizeToFit
className={cn(
className,
'absolute text-center left-0 right-0 opacity-0',
)}
style={{
fontSize: initialFontSize,
lineHeight: initialFontSize,
top: -10000,
}}
onTextLayout={(e) => {
setFontSize(Math.round(e.nativeEvent.lines[0].ascender))
}}
>
{formattedNumbers.map((x) => x.value).join('')}
{suffix}
</Text>
<Animated.View className="flex-row items-end justify-center w-full flex-1 overflow-hidden">
{formattedNumbers.map((formattedNumber) => {
return (
<Animated.View
layout={LinearTransition.duration(animationDuration)}
key={formattedNumber.key}
entering={SlideInDown.duration(
animationDuration,
).withInitialValues({
originY: initialFontSize / 2,
})}
exiting={SlideOutDown.duration(
animationDuration,
).withInitialValues({
transform: [{ translateY: -initialFontSize / 2 }],
})}
>
<Animated.Text
style={[style, { fontSize }]}
className={className}
>
{formattedNumber.value}
</Animated.Text>
</Animated.View>
)
})}
{!!suffix && (
<Animated.View
layout={LinearTransition.duration(animationDuration)}
style={{ marginBottom: fontSize / 6 }}
>
<Animated.Text
style={{ fontSize: fontSize / 3 }}
className={suffixClassName}
>
{suffix}
</Animated.Text>
</Animated.View>
)}
</Animated.View>
</Animated.View>
)
}

0 comments on commit af42cec

Please sign in to comment.