├── .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 |
--------------------------------------------------------------------------------