├── .gitignore ├── README.md ├── app.json ├── app ├── _layout.tsx └── index.tsx ├── assets ├── fonts │ ├── Quicksand-Bold.ttf │ ├── Quicksand-Regular.ttf │ └── Quicksand-SemiBold.ttf └── images │ ├── adaptive-icon.png │ ├── favicon.png │ ├── icon.png │ ├── partial-react-logo.png │ ├── react-logo.png │ ├── react-logo@2x.png │ ├── react-logo@3x.png │ └── splash-icon.png ├── components ├── AnimatedText.tsx ├── NumberPad.tsx ├── ThemedText.tsx ├── ThemedView.tsx └── ui │ ├── Balance.tsx │ └── Recipient.tsx ├── constants ├── Colors.ts └── index.ts ├── hooks ├── useColorScheme.ts ├── useColorScheme.web.ts ├── useNumber.ts ├── useScaleFont.ts └── useThemeColor.ts ├── package-lock.json ├── package.json └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | *.orig.* 14 | *.jks 15 | *.p8 16 | *.p12 17 | *.key 18 | *.mobileprovision 19 | 20 | # Metro 21 | .metro-health-check* 22 | 23 | # debug 24 | npm-debug.* 25 | yarn-debug.* 26 | yarn-error.* 27 | 28 | # macOS 29 | .DS_Store 30 | *.pem 31 | 32 | # local env files 33 | .env*.local 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | 38 | app-example 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Expo app 👋 2 | 3 | This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). 4 | 5 | ## Get started 6 | 7 | 1. Install dependencies 8 | 9 | ```bash 10 | npm install 11 | ``` 12 | 13 | 2. Start the app 14 | 15 | ```bash 16 | npx expo start 17 | ``` 18 | 19 | In the output, you'll find options to open the app in a 20 | 21 | - [development build](https://docs.expo.dev/develop/development-builds/introduction/) 22 | - [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) 23 | - [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) 24 | - [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo 25 | 26 | You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). 27 | 28 | ## Get a fresh project 29 | 30 | When you're ready, run: 31 | 32 | ```bash 33 | npm run reset-project 34 | ``` 35 | 36 | This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. 37 | 38 | ## Learn more 39 | 40 | To learn more about developing your project with Expo, look at the following resources: 41 | 42 | - [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). 43 | - [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. 44 | 45 | ## Join the community 46 | 47 | Join our community of developers creating universal apps. 48 | 49 | - [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. 50 | - [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. 51 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "expo-number-input-transition", 4 | "slug": "expo-number-input-transition", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "myapp", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true 13 | }, 14 | "android": { 15 | "adaptiveIcon": { 16 | "foregroundImage": "./assets/images/adaptive-icon.png", 17 | "backgroundColor": "#ffffff" 18 | } 19 | }, 20 | "web": { 21 | "bundler": "metro", 22 | "output": "static", 23 | "favicon": "./assets/images/favicon.png" 24 | }, 25 | "plugins": [ 26 | "expo-router", 27 | [ 28 | "expo-splash-screen", 29 | { 30 | "image": "./assets/images/splash-icon.png", 31 | "imageWidth": 200, 32 | "resizeMode": "contain", 33 | "backgroundColor": "#ffffff" 34 | } 35 | ] 36 | ], 37 | "experiments": { 38 | "typedRoutes": true 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DarkTheme, 3 | DefaultTheme, 4 | ThemeProvider, 5 | } from "@react-navigation/native"; 6 | import { useFonts } from "expo-font"; 7 | import { Stack } from "expo-router"; 8 | import * as SplashScreen from "expo-splash-screen"; 9 | import { StatusBar } from "expo-status-bar"; 10 | import { useEffect } from "react"; 11 | import "react-native-reanimated"; 12 | 13 | import { useColorScheme } from "@/hooks/useColorScheme"; 14 | import { useThemeColor } from "@/hooks/useThemeColor"; 15 | import { ThemedText } from "@/components/ThemedText"; 16 | import Ionicons from "@expo/vector-icons/Ionicons"; 17 | import { useScaleFont } from "@/hooks/useScaleFont"; 18 | 19 | // Prevent the splash screen from auto-hiding before asset loading is complete. 20 | SplashScreen.preventAutoHideAsync(); 21 | 22 | export default function RootLayout() { 23 | const colorScheme = useColorScheme(); 24 | const backgroundColor = useThemeColor({}, "background"); 25 | 26 | const [loaded] = useFonts({ 27 | QuicksandSemiBold: require("../assets/fonts/Quicksand-SemiBold.ttf"), 28 | QuicksandBold: require("../assets/fonts/Quicksand-Bold.ttf"), 29 | }); 30 | 31 | useEffect(() => { 32 | if (loaded) { 33 | SplashScreen.hideAsync(); 34 | } 35 | }, [loaded]); 36 | 37 | if (!loaded) { 38 | return null; 39 | } 40 | 41 | return ( 42 | 43 | 44 | ( 56 | 61 | 62 | 63 | ), 64 | }} 65 | /> 66 | 67 | 68 | 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /app/index.tsx: -------------------------------------------------------------------------------- 1 | import AnimatedText from "@/components/AnimatedText"; 2 | import { 3 | PixelRatio, 4 | Pressable, 5 | ScrollView, 6 | StyleSheet, 7 | View, 8 | } from "react-native"; 9 | import { ThemedText } from "@/components/ThemedText"; 10 | import { ThemedView } from "@/components/ThemedView"; 11 | import { useThemeColor } from "@/hooks/useThemeColor"; 12 | import NumberPad from "@/components/NumberPad"; 13 | import useNumber from "@/hooks/useNumber"; 14 | import Recipient from "@/components/ui/Recipient"; 15 | import Balance from "@/components/ui/Balance"; 16 | import Animated, { 17 | useAnimatedStyle, 18 | withTiming, 19 | } from "react-native-reanimated"; 20 | 21 | const AnimatedPressable = Animated.createAnimatedComponent(Pressable); 22 | 23 | export default function Home() { 24 | const { 25 | displayValue, 26 | appendDigit, 27 | addDecimalPoint, 28 | replaceDigit, 29 | deleteDigit, 30 | clearAll, 31 | } = useNumber(); 32 | 33 | const text = useThemeColor({}, "text"); 34 | const bg = useThemeColor({}, "background"); 35 | const ripple = useThemeColor({}, "ripple"); 36 | 37 | const buttonAnimatedStyle = useAnimatedStyle(() => { 38 | return { 39 | backgroundColor: withTiming( 40 | parseFloat(displayValue) === 0 ? text + "70" : text, 41 | { duration: 120 } 42 | ), 43 | }; 44 | }); 45 | 46 | return ( 47 | 48 | 53 | 54 | 55 | {displayValue} 56 | 57 | { 60 | replaceDigit(19485); 61 | }} 62 | /> 63 | 64 | { 66 | appendDigit(digit); 67 | }} 68 | onDot={() => { 69 | addDecimalPoint(); 70 | }} 71 | onClear={() => { 72 | clearAll(); 73 | }} 74 | onDelete={() => { 75 | deleteDigit(); 76 | }} 77 | /> 78 | 86 | 94 | Continue 95 | 96 | 97 | 98 | ); 99 | } 100 | 101 | const styles = StyleSheet.create({ 102 | container: { 103 | flex: 1, 104 | justifyContent: "center", 105 | }, 106 | textStyle: { 107 | fontSize: PixelRatio.getPixelSizeForLayoutSize(32), 108 | fontFamily: "QuicksandBold", 109 | lineHeight: 100, 110 | }, 111 | label: { 112 | fontSize: 18, 113 | opacity: 0.7, 114 | marginBottom: 8, 115 | }, 116 | screen: { 117 | width: "100%", 118 | flexGrow: 1, 119 | paddingVertical: 12, 120 | }, 121 | continue: { 122 | marginBottom: 24, 123 | marginTop: 12, 124 | height: 52, 125 | width: "100%", 126 | borderRadius: 50, 127 | alignItems: "center", 128 | justifyContent: "center", 129 | overflow: "hidden", 130 | }, 131 | }); 132 | -------------------------------------------------------------------------------- /assets/fonts/Quicksand-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/fonts/Quicksand-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/Quicksand-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/fonts/Quicksand-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/Quicksand-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/fonts/Quicksand-SemiBold.ttf -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/icon.png -------------------------------------------------------------------------------- /assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/react-logo.png -------------------------------------------------------------------------------- /assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Solarin-Johnson/expo-number-input-transition/a33b8728ce490253c9ea2e26b7e338025482bbb6/assets/images/splash-icon.png -------------------------------------------------------------------------------- /components/AnimatedText.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Platform, 3 | StyleSheet, 4 | TextProps, 5 | useWindowDimensions, 6 | } from "react-native"; 7 | import React, { useMemo } from "react"; 8 | import Animated, { 9 | FadeInDown, 10 | FadeInUp, 11 | FadeOutDown, 12 | FadeOutUp, 13 | LinearTransition, 14 | useAnimatedStyle, 15 | useDerivedValue, 16 | withSpring, 17 | } from "react-native-reanimated"; 18 | import { useScaleFont } from "@/hooks/useScaleFont"; 19 | import { BASE_WIDTH } from "@/constants"; 20 | 21 | interface CharacterObject { 22 | id: string; 23 | char: string; 24 | } 25 | 26 | const springConfig = { 27 | damping: 24, 28 | stiffness: 180, 29 | }; 30 | 31 | const AnimatedText = (props: TextProps & { size: number }) => { 32 | const { children, size, ...rest } = props; 33 | const { width } = useWindowDimensions(); 34 | const isWeb = Platform.OS === "web"; 35 | 36 | const splitText: CharacterObject[] = useMemo(() => { 37 | if (typeof children !== "string" && typeof children !== "number") { 38 | return []; 39 | } 40 | 41 | let commaCount = 0; 42 | const stringValue = children.toString(); 43 | return stringValue.split("").map((char, index) => ({ 44 | id: 45 | char === "," 46 | ? `comma-${++commaCount}-${index}` 47 | : `${index - commaCount}-${char}`, 48 | char, 49 | })); 50 | }, [children]); 51 | 52 | const scaleFont = useScaleFont(); 53 | 54 | const textStyle = useMemo( 55 | () => ({ 56 | fontSize: scaleFont(size), 57 | }), 58 | [size] 59 | ); 60 | 61 | const animatedScale = useDerivedValue(() => { 62 | const len = splitText.filter((item) => item.char !== ",").length; 63 | const scaleRatio = isWeb ? 0.9 : Math.min(0.9, BASE_WIDTH / width); 64 | 65 | if (len > 6) { 66 | return scaleRatio * 0.56; 67 | } 68 | return 1; 69 | }); 70 | 71 | const textScaleAnimatedStyle = useAnimatedStyle(() => ({ 72 | transform: [{ scale: withSpring(animatedScale.value, springConfig) }], 73 | })); 74 | 75 | const getAnimation = useMemo(() => { 76 | return (index: number, id: string) => { 77 | if (id === "0-0") { 78 | return { 79 | entering: FadeInUp.duration(120), 80 | exiting: FadeOutUp.duration(120), 81 | }; 82 | } 83 | 84 | if (index === splitText.length - 1 || id.includes("comma")) { 85 | return { 86 | entering: FadeInDown.duration(120), 87 | exiting: FadeOutDown.duration(120), 88 | }; 89 | } 90 | }; 91 | }, [splitText.length]); 92 | 93 | const layoutConfig = LinearTransition.springify() 94 | .damping(springConfig.damping) 95 | .stiffness(springConfig.stiffness); 96 | 97 | return ( 98 | 99 | 100 | 101 | 102 | {"$"} 103 | 104 | 105 | {splitText.map(({ char, id }, index) => ( 106 | 111 | 112 | {char} 113 | 114 | 115 | ))} 116 | 117 | 118 | ); 119 | }; 120 | 121 | const styles = StyleSheet.create({ 122 | cover: { 123 | width: "100%", 124 | alignItems: "center", 125 | flex: 1, 126 | justifyContent: "center", 127 | }, 128 | container: { 129 | flexDirection: "row", 130 | marginHorizontal: 24, 131 | marginLeft: 0, 132 | }, 133 | currency: { 134 | transform: [ 135 | { scale: 0.45 }, 136 | { 137 | translateX: "-5%", 138 | }, 139 | { 140 | translateY: "45%", 141 | }, 142 | ], 143 | transformOrigin: "top right", 144 | }, 145 | }); 146 | 147 | export default React.memo(AnimatedText); 148 | -------------------------------------------------------------------------------- /components/NumberPad.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react"; 2 | import { View, TouchableOpacity, StyleSheet, Pressable } from "react-native"; 3 | import { ThemedText } from "./ThemedText"; 4 | import Ionicons from "@expo/vector-icons/Ionicons"; 5 | import Entypo from "@expo/vector-icons/Entypo"; 6 | import { ThemedView } from "./ThemedView"; 7 | import { useScaleFont } from "@/hooks/useScaleFont"; 8 | import { useThemeColor } from "@/hooks/useThemeColor"; 9 | 10 | interface NumberPadProps { 11 | onPress: (value: number) => void; 12 | onDelete?: () => void; 13 | onClear?: () => void; 14 | onDot?: () => void; 15 | showDot?: boolean; 16 | } 17 | 18 | const NumberPad: React.FC = memo( 19 | ({ onPress, onDelete, onClear, onDot, showDot = true }) => { 20 | const scaleFont = useScaleFont(); 21 | const ripple = useThemeColor({}, "ripple"); 22 | 23 | const renderButton = React.useCallback( 24 | (value: string) => { 25 | const isSpecialButton = value === "delete" || value === "clear"; 26 | 27 | const handlePress = () => { 28 | if (value === "delete") { 29 | onDelete?.(); 30 | } else if (value === "clear") { 31 | onClear?.(); 32 | } else if (value === "dot") { 33 | onDot?.(); 34 | } else { 35 | onPress(parseInt(value)); 36 | } 37 | }; 38 | 39 | return ( 40 | { 45 | if (value === "delete") { 46 | onClear?.(); 47 | } 48 | }} 49 | android_ripple={{ 50 | color: ripple, 51 | borderless: true, 52 | radius: 42, 53 | foreground: true, 54 | }} 55 | > 56 | {value === "delete" ? ( 57 | 58 | 59 | 60 | ) : value === "dot" ? ( 61 | 62 | 63 | 64 | ) : ( 65 | 68 | {value} 69 | 70 | )} 71 | 72 | ); 73 | }, 74 | [onDelete, onClear, onPress] 75 | ); 76 | 77 | const numbers = [ 78 | ["1", "2", "3"], 79 | ["4", "5", "6"], 80 | ["7", "8", "9"], 81 | [showDot ? "dot" : "", "0", "delete"], 82 | ]; 83 | 84 | return ( 85 | 86 | {numbers.map((row, rowIndex) => ( 87 | 88 | {row.map((value) => 89 | value ? ( 90 | renderButton(value) 91 | ) : ( 92 | 93 | ) 94 | )} 95 | 96 | ))} 97 | 98 | ); 99 | } 100 | ); 101 | 102 | const styles = StyleSheet.create({ 103 | container: { 104 | flex: 1, 105 | padding: 8, 106 | width: "100%", 107 | justifyContent: "flex-end", 108 | minHeight: 200, 109 | maxHeight: 300, 110 | }, 111 | row: { 112 | flexDirection: "row", 113 | justifyContent: "space-around", 114 | flex: 1, 115 | }, 116 | button: { 117 | flex: 1, 118 | justifyContent: "center", 119 | alignItems: "center", 120 | }, 121 | buttonText: {}, 122 | specialButton: {}, 123 | specialButtonText: { 124 | fontSize: 20, 125 | }, 126 | }); 127 | 128 | export default NumberPad; 129 | -------------------------------------------------------------------------------- /components/ThemedText.tsx: -------------------------------------------------------------------------------- 1 | import { Text, type TextProps, StyleSheet } from "react-native"; 2 | 3 | import { useThemeColor } from "@/hooks/useThemeColor"; 4 | 5 | export type ThemedTextProps = TextProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link"; 9 | }; 10 | 11 | export function ThemedText({ 12 | style, 13 | lightColor, 14 | darkColor, 15 | type = "default", 16 | ...rest 17 | }: ThemedTextProps) { 18 | const color = useThemeColor({ light: lightColor, dark: darkColor }, "text"); 19 | 20 | return ( 21 | 33 | ); 34 | } 35 | 36 | const styles = StyleSheet.create({ 37 | default: { 38 | fontSize: 16, 39 | fontFamily: "QuicksandBold", 40 | }, 41 | defaultSemiBold: { 42 | fontSize: 16, 43 | lineHeight: 24, 44 | fontWeight: "600", 45 | }, 46 | title: { 47 | fontSize: 32, 48 | fontWeight: "bold", 49 | lineHeight: 32, 50 | }, 51 | subtitle: { 52 | fontSize: 20, 53 | fontWeight: "bold", 54 | }, 55 | link: { 56 | lineHeight: 30, 57 | fontSize: 16, 58 | color: "#0a7ea4", 59 | }, 60 | }); 61 | -------------------------------------------------------------------------------- /components/ThemedView.tsx: -------------------------------------------------------------------------------- 1 | import { View, type ViewProps } from 'react-native'; 2 | 3 | import { useThemeColor } from '@/hooks/useThemeColor'; 4 | 5 | export type ThemedViewProps = ViewProps & { 6 | lightColor?: string; 7 | darkColor?: string; 8 | }; 9 | 10 | export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) { 11 | const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background'); 12 | 13 | return ; 14 | } 15 | -------------------------------------------------------------------------------- /components/ui/Balance.tsx: -------------------------------------------------------------------------------- 1 | import { useThemeColor } from "@/hooks/useThemeColor"; 2 | import React from "react"; 3 | import { View, StyleSheet, TouchableOpacity } from "react-native"; 4 | import { ThemedText } from "../ThemedText"; 5 | import { ThemedView } from "../ThemedView"; 6 | import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; 7 | import { useScaleFont } from "@/hooks/useScaleFont"; 8 | 9 | interface RecipientProps { 10 | onPress: () => void; 11 | balance: number; 12 | } 13 | 14 | const Balance: React.FC = ({ onPress, balance }) => { 15 | const backgroundColor = useThemeColor({}, "foreground"); 16 | const text = useThemeColor({}, "text"); 17 | const ripple = useThemeColor({}, "ripple"); 18 | const scaleFont = useScaleFont(); 19 | 20 | const formattedBalance = new Intl.NumberFormat("en-US", { 21 | style: "currency", 22 | currency: "USD", 23 | }).format; 24 | 25 | return ( 26 | 27 | 34 | 35 | 36 | 37 | 38 | 39 | 45 | Balance 46 | 47 | 53 | {formattedBalance(balance)} 54 | 55 | 56 | 65 | Use Max 66 | 67 | 68 | ); 69 | }; 70 | 71 | const styles = StyleSheet.create({ 72 | container: { 73 | flexDirection: "row", 74 | paddingVertical: 8, 75 | padding: 12, 76 | marginHorizontal: 16, 77 | borderRadius: 16, 78 | gap: 12, 79 | alignItems: "center", 80 | }, 81 | button: { 82 | paddingVertical: 8, 83 | paddingHorizontal: 16, 84 | borderRadius: 15, 85 | backgroundColor: "#f4f4f4", 86 | alignItems: "center", 87 | justifyContent: "center", 88 | }, 89 | }); 90 | 91 | export default Balance; 92 | -------------------------------------------------------------------------------- /components/ui/Recipient.tsx: -------------------------------------------------------------------------------- 1 | import { useThemeColor } from "@/hooks/useThemeColor"; 2 | import React from "react"; 3 | import { View, StyleSheet } from "react-native"; 4 | import { ThemedText } from "../ThemedText"; 5 | import { useScaleFont } from "@/hooks/useScaleFont"; 6 | 7 | interface RecipientProps { 8 | names: string[]; 9 | } 10 | 11 | const Recipient: React.FC = ({ names }) => { 12 | const backgroundColor = useThemeColor({}, "foreground"); 13 | const text = useThemeColor({}, "text"); 14 | const scaleFont = useScaleFont(); 15 | 16 | return ( 17 | 18 | 19 | To: 20 | 21 | 22 | {names.map((name, index) => ( 23 | 30 | {name} 31 | 32 | ))} 33 | 34 | 35 | ); 36 | }; 37 | 38 | const styles = StyleSheet.create({ 39 | container: { 40 | flexDirection: "row", 41 | paddingVertical: 8, 42 | paddingHorizontal: 12, 43 | marginHorizontal: 16, 44 | borderRadius: 15, 45 | marginVertical: 8, 46 | alignItems: "center", 47 | }, 48 | label: { 49 | marginRight: 6, 50 | fontSize: 15, 51 | opacity: 0.5, 52 | padding: 4, 53 | }, 54 | namesContainer: { 55 | flexDirection: "row", 56 | flexWrap: "wrap", 57 | gap: 2, 58 | }, 59 | name: { 60 | fontSize: 15, 61 | marginRight: 4, 62 | paddingHorizontal: 12, 63 | paddingVertical: 6, 64 | borderRadius: 8, 65 | }, 66 | }); 67 | 68 | export default Recipient; 69 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Below are the colors that are used in the app. The colors are defined in the light and dark mode. 3 | * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc. 4 | */ 5 | 6 | const tintColorLight = "#0a7ea4"; 7 | const tintColorDark = "#fff"; 8 | 9 | export const Colors = { 10 | light: { 11 | text: "#11181C", 12 | background: "#fff", 13 | foreground: "#f9f9f9", 14 | ripple: "#B4B4B400", 15 | tint: tintColorLight, 16 | }, 17 | dark: { 18 | text: "#ECEDEE", 19 | background: "#030003", 20 | foreground: "#110F11", 21 | ripple: "#84848420", 22 | tint: tintColorDark, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const BASE_WIDTH = 375; 2 | -------------------------------------------------------------------------------- /hooks/useColorScheme.ts: -------------------------------------------------------------------------------- 1 | export { useColorScheme } from 'react-native'; 2 | -------------------------------------------------------------------------------- /hooks/useColorScheme.web.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { useColorScheme as useRNColorScheme } from 'react-native'; 3 | 4 | /** 5 | * To support static rendering, this value needs to be re-calculated on the client side for web 6 | */ 7 | export function useColorScheme() { 8 | const [hasHydrated, setHasHydrated] = useState(false); 9 | 10 | useEffect(() => { 11 | setHasHydrated(true); 12 | }, []); 13 | 14 | const colorScheme = useRNColorScheme(); 15 | 16 | if (hasHydrated) { 17 | return colorScheme; 18 | } 19 | 20 | return 'light'; 21 | } 22 | -------------------------------------------------------------------------------- /hooks/useNumber.ts: -------------------------------------------------------------------------------- 1 | import { useReducer } from "react"; 2 | 3 | interface NumberState { 4 | displayValue: string; 5 | value: number; 6 | isDecimal: boolean; 7 | decimalPlaces: number; 8 | } 9 | 10 | interface Action { 11 | type: string; 12 | digit?: number; 13 | } 14 | 15 | const MAX_DIGITS = 13; 16 | const MAX_DECIMAL_PLACES = 2; 17 | 18 | function numberReducer(state: NumberState, action: Action): NumberState { 19 | switch (action.type) { 20 | case "APPEND_DIGIT": 21 | return handleAppendDigit(state, action.digit!); 22 | 23 | case "DECIMAL_POINT": 24 | return handleDecimalPoint(state); 25 | 26 | case "REPLACE_DIGIT": 27 | return handleReplaceDigit(state, action.digit!); 28 | 29 | case "DELETE_DIGIT": 30 | return handleDeleteDigit(state); 31 | 32 | case "CLEAR_ALL": 33 | return handleClearAll(); 34 | 35 | default: 36 | return state; 37 | } 38 | } 39 | 40 | function handleReplaceDigit(state: NumberState, digit: number): NumberState { 41 | if (digit === 0) { 42 | return { 43 | value: 0, 44 | isDecimal: false, 45 | decimalPlaces: 0, 46 | displayValue: "0", 47 | }; 48 | } 49 | 50 | const newDisplayValue = digit.toString(); 51 | 52 | const formattedValue = formatDisplayValue(newDisplayValue); 53 | 54 | const rawValue = parseFloat(newDisplayValue.replace(/,/g, "")); 55 | 56 | return { 57 | ...state, 58 | displayValue: formattedValue, 59 | value: rawValue, 60 | isDecimal: false, 61 | decimalPlaces: 0, 62 | }; 63 | } 64 | 65 | function handleAppendDigit(state: NumberState, digit: number): NumberState { 66 | if (state.displayValue.replace(/[.,]/g, "").length >= MAX_DIGITS) { 67 | return state; 68 | } 69 | 70 | if (state.isDecimal && state.decimalPlaces >= MAX_DECIMAL_PLACES) { 71 | return state; 72 | } 73 | 74 | let newDisplayValue = state.displayValue; 75 | 76 | if (state.displayValue === "0" && digit !== 0) { 77 | newDisplayValue = digit.toString(); 78 | } else { 79 | newDisplayValue = state.displayValue + digit.toString(); 80 | } 81 | 82 | const rawValue = parseFloat(newDisplayValue.replace(/,/g, "")); 83 | const formattedValue = formatDisplayValue(newDisplayValue); 84 | 85 | return { 86 | ...state, 87 | displayValue: formattedValue, 88 | value: rawValue, 89 | decimalPlaces: state.isDecimal ? state.decimalPlaces + 1 : 0, 90 | }; 91 | } 92 | 93 | function handleDecimalPoint(state: NumberState): NumberState { 94 | if (state.isDecimal) { 95 | return state; 96 | } 97 | 98 | return { 99 | ...state, 100 | isDecimal: true, 101 | displayValue: state.displayValue + ".", 102 | }; 103 | } 104 | 105 | function handleDeleteDigit(state: NumberState): NumberState { 106 | if (state.displayValue.length <= 1) { 107 | return { 108 | value: 0, 109 | isDecimal: false, 110 | decimalPlaces: 0, 111 | displayValue: "0", 112 | }; 113 | } 114 | 115 | const newValue = state.displayValue.slice(0, -1); 116 | const rawNewValue = parseFloat(newValue.replace(/,/g, "") || "0"); 117 | const formattedNewValue = formatDisplayValue(newValue); 118 | 119 | return { 120 | ...state, 121 | displayValue: formattedNewValue, 122 | value: rawNewValue, 123 | isDecimal: newValue.includes("."), 124 | decimalPlaces: newValue.includes(".") 125 | ? newValue.split(".")[1]?.length || 0 126 | : 0, 127 | }; 128 | } 129 | 130 | function handleClearAll(): NumberState { 131 | return { 132 | value: 0, 133 | isDecimal: false, 134 | decimalPlaces: 0, 135 | displayValue: "0", 136 | }; 137 | } 138 | 139 | function formatDisplayValue(displayValue: string): string { 140 | const [integerPart, decimalPart = ""] = displayValue.split("."); 141 | const formattedInteger = addCommasToInteger(integerPart); 142 | 143 | if (decimalPart) { 144 | return `${formattedInteger}.${decimalPart}`; 145 | } 146 | 147 | return formattedInteger; 148 | } 149 | 150 | function addCommasToInteger(integerPart: string): string { 151 | const number = parseFloat(integerPart.replace(/,/g, "")); 152 | return number.toLocaleString("en-US"); 153 | } 154 | 155 | function useNumber() { 156 | const [state, dispatch] = useReducer(numberReducer, { 157 | displayValue: "0", 158 | value: 0, 159 | isDecimal: false, 160 | decimalPlaces: 0, 161 | }); 162 | 163 | const appendDigit = (digit: number) => { 164 | dispatch({ type: "APPEND_DIGIT", digit }); 165 | }; 166 | 167 | const addDecimalPoint = () => { 168 | dispatch({ type: "DECIMAL_POINT" }); 169 | }; 170 | 171 | const replaceDigit = (digit: number) => { 172 | dispatch({ type: "REPLACE_DIGIT", digit }); 173 | }; 174 | 175 | const deleteDigit = () => { 176 | dispatch({ type: "DELETE_DIGIT" }); 177 | }; 178 | 179 | const clearAll = () => { 180 | dispatch({ type: "CLEAR_ALL" }); 181 | }; 182 | 183 | return { 184 | displayValue: state.displayValue, 185 | value: state.value, 186 | isDecimal: state.isDecimal, 187 | decimalPlaces: state.decimalPlaces, 188 | appendDigit, 189 | addDecimalPoint, 190 | replaceDigit, 191 | deleteDigit, 192 | clearAll, 193 | }; 194 | } 195 | 196 | export default useNumber; 197 | -------------------------------------------------------------------------------- /hooks/useScaleFont.ts: -------------------------------------------------------------------------------- 1 | import { BASE_WIDTH } from "@/constants"; 2 | import { useMemo } from "react"; 3 | import { PixelRatio, Platform, useWindowDimensions } from "react-native"; 4 | 5 | export function useScaleFont() { 6 | const { width } = useWindowDimensions(); 7 | const isWeb = Platform.OS === "web"; 8 | 9 | return useMemo( 10 | () => (size: number) => { 11 | return isWeb 12 | ? size 13 | : ((size * width) / BASE_WIDTH) * (1 / PixelRatio.getFontScale()); 14 | }, 15 | [width] 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /hooks/useThemeColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Learn more about light and dark modes: 3 | * https://docs.expo.dev/guides/color-schemes/ 4 | */ 5 | 6 | import { Colors } from '@/constants/Colors'; 7 | import { useColorScheme } from '@/hooks/useColorScheme'; 8 | 9 | export function useThemeColor( 10 | props: { light?: string; dark?: string }, 11 | colorName: keyof typeof Colors.light & keyof typeof Colors.dark 12 | ) { 13 | const theme = useColorScheme() ?? 'light'; 14 | const colorFromProps = props[theme]; 15 | 16 | if (colorFromProps) { 17 | return colorFromProps; 18 | } else { 19 | return Colors[theme][colorName]; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "expo-number-input-transition", 3 | "main": "expo-router/entry", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo start --android", 9 | "ios": "expo start --ios", 10 | "web": "expo start --web", 11 | "test": "jest --watchAll", 12 | "lint": "expo lint" 13 | }, 14 | "jest": { 15 | "preset": "jest-expo" 16 | }, 17 | "dependencies": { 18 | "@expo/vector-icons": "^14.0.2", 19 | "@react-navigation/bottom-tabs": "^7.2.0", 20 | "@react-navigation/native": "^7.0.14", 21 | "expo": "~52.0.38", 22 | "expo-blur": "~14.0.3", 23 | "expo-constants": "~17.0.8", 24 | "expo-font": "~13.0.4", 25 | "expo-haptics": "~14.0.1", 26 | "expo-linking": "~7.0.5", 27 | "expo-router": "~4.0.18", 28 | "expo-splash-screen": "~0.29.22", 29 | "expo-status-bar": "~2.0.1", 30 | "expo-symbols": "~0.2.2", 31 | "expo-system-ui": "~4.0.8", 32 | "expo-web-browser": "~14.0.2", 33 | "react": "18.3.1", 34 | "react-dom": "18.3.1", 35 | "react-native": "0.76.7", 36 | "react-native-gesture-handler": "~2.20.2", 37 | "react-native-reanimated": "~3.16.1", 38 | "react-native-safe-area-context": "4.12.0", 39 | "react-native-screens": "~4.4.0", 40 | "react-native-web": "~0.19.13", 41 | "react-native-webview": "13.12.5" 42 | }, 43 | "devDependencies": { 44 | "@babel/core": "^7.25.2", 45 | "@types/jest": "^29.5.12", 46 | "@types/react": "~18.3.12", 47 | "@types/react-test-renderer": "^18.3.0", 48 | "jest": "^29.2.1", 49 | "jest-expo": "~52.0.6", 50 | "react-test-renderer": "18.3.1", 51 | "typescript": "^5.3.3" 52 | }, 53 | "private": true 54 | } 55 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": [ 7 | "./*" 8 | ] 9 | } 10 | }, 11 | "include": [ 12 | "**/*.ts", 13 | "**/*.tsx", 14 | ".expo/types/**/*.ts", 15 | "expo-env.d.ts" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------