Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(mobile): [Transaction] add basic transaction amount input #93

Merged
merged 1 commit into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
)
}