Skip to content
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
32 changes: 10 additions & 22 deletions dapps/pos-app/app/amount.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BigAmountInput } from "@/components/big-amount-input";
import { Button } from "@/components/button";
import { NumericKeyboard } from "@/components/numeric-keyboard";
import { ThemedText } from "@/components/themed-text";
Expand Down Expand Up @@ -65,20 +66,12 @@ export default function AmountScreen() {
{ borderColor: Theme["border-primary"] },
]}
>
<ThemedText
numberOfLines={1}
style={[
styles.amountText,
{
color:
watchAmount === ""
? Theme["text-secondary"]
: Theme["text-primary"],
},
]}
>
{formatAmountWithSymbol(watchAmount || "0.00", currency)}
</ThemedText>
<BigAmountInput
value={watchAmount}
currency={currency.symbol}
symbolPosition={currency.symbolPosition}
isFocused={false}
/>
</View>
<Controller
control={control}
Expand Down Expand Up @@ -112,10 +105,10 @@ export default function AmountScreen() {
}
onChange?.(newDisplay);
} else {
// Limit to 2 decimal digits
// Limit to 2 decimal places
if (prev.includes(".")) {
const [, decimal] = prev.split(".");
if (decimal.length >= 2) return;
const decimalPart = prev.split(".")[1] || "";
if (decimalPart.length >= 2) return;
}
const newDisplay = prev === "0" ? key : prev + key;
onChange?.(newDisplay);
Expand Down Expand Up @@ -166,11 +159,6 @@ const styles = StyleSheet.create({
paddingTop: Spacing["spacing-4"],
paddingHorizontal: Spacing["spacing-5"],
},
amountText: {
fontSize: 50,
textAlign: "center",
lineHeight: 50,
},
button: {
width: "100%",
marginTop: Spacing["spacing-6"],
Expand Down
43 changes: 43 additions & 0 deletions dapps/pos-app/components/big-amount-input/BigAmountInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { StyleSheet, View } from "react-native";
import type { BigAmountInputProps } from "./BigAmountInput.types";
import { AnimatedNumber } from "./components/AnimatedNumber";

/**
* Animated currency display with per-character animations.
* This is a display-only component - use with NumericKeyboard for input.
*/
export const BigAmountInput = ({
value = "",
currency = "$",
symbolPosition = "left",
locale,
placeholder = "0.00",
isFocused = true,
cursorBlinkEnabled = true,
testID,
style,
}: BigAmountInputProps) => {
return (
<View style={[styles.container, style]} testID={testID}>
<AnimatedNumber
value={value}
currency={currency}
symbolPosition={symbolPosition}
locale={locale}
placeholder={placeholder}
isFocused={isFocused}
cursorBlinkEnabled={cursorBlinkEnabled}
/>
</View>
);
};

const styles = StyleSheet.create({
container: {
alignItems: "center",
justifyContent: "center",
minHeight: 80,
flex: 1,
width: "100%",
},
});
43 changes: 43 additions & 0 deletions dapps/pos-app/components/big-amount-input/BigAmountInput.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { StyleProp, ViewStyle } from "react-native";
import type { SupportedLocale, SymbolPosition } from "./utils/formatAmount";

export type BigAmountInputProps = {
/**
* Current raw value (e.g., "24.00" for $24.00)
*/
value?: string;
/**
* Currency symbol to display (default: $)
*/
currency?: string;
/**
* Position of the currency symbol (default: left)
* "left" for "$10.00", "right" for "10.00€"
*/
symbolPosition?: SymbolPosition;
/**
* Locale for number formatting (en-US, fr-FR, de-DE, nl-NL)
*/
locale?: SupportedLocale;
/**
* Placeholder text when empty
*/
placeholder?: string;
/**
* Whether the input is focused (shows blinking cursor)
*/
isFocused?: boolean;
/**
* Whether cursor blinks when focused
* @default true
*/
cursorBlinkEnabled?: boolean;
/**
* Test ID for testing
*/
testID?: string;
/**
* Style for the container
*/
style?: StyleProp<ViewStyle>;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import type { CharacterItem } from "../utils/getCharactersArray";
import { useTheme } from "@/hooks/use-theme-color";
import { memo, useEffect } from "react";
import { StyleSheet } from "react-native";
import Animated, {
Easing,
useAnimatedStyle,
useSharedValue,
withTiming,
} from "react-native-reanimated";
import type { ExitAnimationsValues } from "react-native-reanimated";

const easeOutExpo = Easing.out(Easing.exp);
const TIMING_CONFIG = { duration: 200, easing: easeOutExpo };

const exitAnimation = (_values: ExitAnimationsValues) => {
"worklet";
return {
animations: {
opacity: withTiming(0, TIMING_CONFIG),
transform: [{ translateY: withTiming(10, TIMING_CONFIG) }],
},
initialValues: {
opacity: 1,
transform: [{ translateY: 0 }],
},
};
};

type AnimatedCharacterProps = {
item: CharacterItem;
scale: number;
characterWidth: number;
itemHeight: number;
positionX: number;
isPlaceholder?: boolean;
};

function AnimatedCharacterComponent({
item,
scale,
characterWidth,
itemHeight,
positionX,
isPlaceholder = false,
}: AnimatedCharacterProps) {
const Theme = useTheme();

const translateY = useSharedValue(10);
const opacity = useSharedValue(0);

useEffect(() => {
translateY.value = withTiming(0, TIMING_CONFIG);
opacity.value = withTiming(1, TIMING_CONFIG);
}, [translateY, opacity]);

// Compensate for scale origin: RN scales from center, so we offset
// by half the width * (1 - scale) to simulate left-origin scaling
const scaleOffsetX = (characterWidth * (1 - scale)) / 2;

const animatedStyle = useAnimatedStyle(
() => ({
opacity: opacity.value,
transform: [
{
translateX: withTiming(positionX - scaleOffsetX, TIMING_CONFIG),
},
{ translateY: translateY.value },
{ scale: withTiming(scale, TIMING_CONFIG) },
],
}),
[positionX, scale, scaleOffsetX],
);

const isPlaceholderChar = isPlaceholder || item.isPlaceholderDecimal;
const textColor = isPlaceholderChar
? Theme["text-secondary"]
: Theme["text-primary"];

return (
<Animated.View exiting={exitAnimation}>
<Animated.View
style={[
styles.container,
{ width: characterWidth, height: itemHeight },
animatedStyle,
]}
>
<Animated.Text style={[styles.text, { color: textColor }]}>
{item.char}
</Animated.Text>
</Animated.View>
</Animated.View>
);
}

export const AnimatedCharacter = memo(AnimatedCharacterComponent);

const styles = StyleSheet.create({
container: {
position: "absolute",
justifyContent: "center",
},
text: {
fontFamily: "KH Teka",
fontSize: 64,
position: "absolute",
textAlign: "center",
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useTheme } from "@/hooks/use-theme-color";
import { useEffect } from "react";
import { StyleSheet } from "react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withSequence,
withTiming,
} from "react-native-reanimated";

const CURSOR_HEIGHT = 56;

type AnimatedCursorProps = {
isFocused: boolean;
cursorPosition: number;
scale: number;
blinkEnabled?: boolean;
containerHeight: number;
};

export const AnimatedCursor = ({
isFocused,
cursorPosition,
scale,
blinkEnabled = true,
containerHeight,
}: AnimatedCursorProps) => {
const Theme = useTheme();
const opacity = useSharedValue(1);

useEffect(() => {
if (isFocused) {
if (blinkEnabled) {
opacity.value = withRepeat(
withSequence(
withTiming(1, { duration: 0 }),
withTiming(1, { duration: 500, easing: Easing.linear }),
withTiming(0, { duration: 0 }),
withTiming(0, { duration: 500, easing: Easing.linear }),
),
-1,
false,
);
} else {
opacity.value = 1;
}
} else {
opacity.value = withTiming(0, { duration: 100 });
}

return () => {
cancelAnimation(opacity);
};
}, [isFocused, opacity, blinkEnabled]);

// Scale height directly instead of transform to avoid position shift
const scaledHeight = CURSOR_HEIGHT * scale;
const topPosition = (containerHeight - scaledHeight) / 2;

const animatedStyle = useAnimatedStyle(
() => ({
opacity: opacity.value,
height: withTiming(scaledHeight, {
duration: 200,
easing: Easing.out(Easing.ease),
}),
transform: [
{
translateX: withTiming(cursorPosition, {
duration: 200,
easing: Easing.out(Easing.ease),
}),
},
{
translateY: withTiming(topPosition, {
duration: 200,
easing: Easing.out(Easing.ease),
}),
},
],
}),
[cursorPosition, scaledHeight, topPosition],
);

if (!isFocused) return null;

return (
<Animated.View
style={[
styles.cursor,
animatedStyle,
{ backgroundColor: Theme["bg-accent-primary"] },
]}
/>
);
};

const styles = StyleSheet.create({
cursor: {
position: "absolute",
width: 2,
borderRadius: 1,
},
});
Loading