import React, { useCallback, useReducer, useRef, useState, useEffect } from "react";
import {
Dimensions,
Platform,
Pressable,
StatusBar,
StyleSheet,
Text,
View,
useColorScheme,
} from "react-native";
import * as Clipboard from "expo-clipboard";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from "react-native-reanimated";
import * as Haptics from "expo-haptics";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useColors } from "@/hooks/useColors";
const { width } = Dimensions.get("window");
const BUTTON_SIZE = Math.floor((width - 64) / 4);
type CalcAction =
| { type: "DIGIT"; value: string }
| { type: "OPERATOR"; value: string }
| { type: "EQUALS" }
| { type: "CLEAR" }
| { type: "TOGGLE_SIGN" }
| { type: "PERCENT" }
| { type: "DECIMAL" }
| { type: "BACKSPACE" };
type CalcState = {
display: string;
expression: string;
operand: string | null;
operator: string | null;
waiting: boolean;
hasResult: boolean;
};
const initialState: CalcState = {
display: "0",
expression: "",
operand: null,
operator: null,
waiting: false,
hasResult: false,
};
function calculate(a: number, b: number, op: string): number {
switch (op) {
case "+":
return a + b;
case "−":
return a - b;
case "×":
return a * b;
case "÷":
return b === 0 ? NaN : a / b;
default:
return b;
}
}
function safeDisplay(value: string) {
if (value === "Error") return value;
const num = Number(value);
if (Number.isNaN(num)) return "Error";
if (!Number.isFinite(num)) return "Error";
if (Math.abs(num) > 1e15) return num.toExponential(6);
return value;
}
function reducer(state: CalcState, action: CalcAction): CalcState {
switch (action.type) {
case "DIGIT": {
if (state.hasResult) {
return { ...initialState, display: action.value };
}
if (state.waiting) {
return { ...state, display: action.value, waiting: false };
}
if (state.display === "0") {
return { ...state, display: action.value };
}
return { ...state, display: state.display + action.value };
}
case "DECIMAL": {
if (state.waiting || state.hasResult) {
return { ...state, display: "0", waiting: false, hasResult: false };
}
if (state.display.includes(".")) return state;
return { ...state, display: state.display + "." };
}
case "OPERATOR": {
const current = Number(state.display);
if (state.operator && state.operand !== null && !state.waiting) {
const result = calculate(
Number(state.operand),
current,
state.operator
);
const resultStr = String(result);
return {
...state,
display: safeDisplay(resultStr),
operand: resultStr,
operator: action.value,
expression: `${resultStr} ${action.value}`,
waiting: true,
};
}
return {
...state,
operand: state.display,
operator: action.value,
expression: `${state.display} ${action.value}`,
waiting: true,
};
}
case "EQUALS": {
if (!state.operator || state.operand === null) return state;
const result = calculate(
Number(state.operand),
Number(state.display),
state.operator
);
const resultStr = String(result);
return {
...state,
display: safeDisplay(resultStr),
expression: `${state.operand} ${state.operator} ${state.display} =`,
operator: null,
operand: null,
hasResult: true,
};
}
case "CLEAR":
return initialState;
case "BACKSPACE": {
if (state.waiting || state.hasResult) return state;
const next = state.display.slice(0, -1);
return {
...state,
display: next.length ? next : "0",
};
}
case "TOGGLE_SIGN": {
if (state.display === "0" || state.display === "Error") return state;
return {
...state,
display: state.display.startsWith("-")
? state.display.slice(1)
: "-" + state.display,
};
}
case "PERCENT": {
const value = Number(state.display);
if (Number.isNaN(value)) return state;
return {
...state,
display: String(value / 100),
};
}
default:
return state;
}
}
type ButtonKind = "number" | "operator" | "equal" | "func";
function CalcButton({
label,
kind,
wide,
onPress,
}: {
label: string;
kind: ButtonKind;
wide?: boolean;
onPress: () => void;
}) {
const colors = useColors();
const scale = useSharedValue(1);
const anim = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const press = () => {
scale.value = withSequence(
withTiming(0.88, { duration: 80 }),
withTiming(1, { duration: 100 })
);
if (Platform.OS !== "web") {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
onPress();
};
const bg =
kind === "number"
? colors.numberBtn
: kind === "operator"
? colors.operatorBtn
: kind === "equal"
? colors.equalBtn
: colors.funcBtn;
const color =
kind === "number"
? colors.numberText
: kind === "operator"
? colors.operatorText
: kind === "equal"
? colors.equalText
: colors.funcText;
return (
<Pressable onPress={press} style={{ margin: 4 }}>
<Animated.View
style={[
styles.button,
anim,
{
width: wide ? BUTTON_SIZE * 2 + 8 : BUTTON_SIZE,
height: BUTTON_SIZE,
backgroundColor: bg,
borderRadius: BUTTON_SIZE / 2,
},
]}
>
<Text style={[styles.text, { color }]}>{label}
</Animated.View>
);
}
export default function CalculatorScreen() {
const [state, dispatch] = useReducer(reducer, initialState);
const colors = useColors();
const insets = useSafeAreaInsets();
const isDark = useColorScheme() === "dark";
const scale = useSharedValue(1);
const displayAnim = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const animate = () => {
scale.value = withSequence(
withTiming(0.95, { duration: 60 }),
withTiming(1, { duration: 100 })
);
};
const dispatchAction = useCallback((action: CalcAction) => {
if (action.type === "OPERATOR" || action.type === "EQUALS") {
animate();
}
dispatch(action);
}, []);
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType | null>(null);
const copy = useCallback(async () => {
if (state.display === "Error") return;
await Clipboard.setStringAsync(state.display);
if (Platform.OS !== "web") {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1200);
}, [state.display]);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const value = safeDisplay(state.display);
const fontSize =
value.length > 12 ? 36 : value.length > 8 ? 48 : 64;
const top = insets.top + 20;
const bottom = insets.bottom + 16;
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<StatusBar barStyle={isDark ? "light-content" : "dark-content"} />
<Pressable
onPress={copy}
style={[
styles.display,
{ backgroundColor: colors.displayBg, paddingTop: top },
]}
>
<Text style={[styles.expr, { color: colors.mutedForeground }]}>
{state.expression}
</Text>
<Animated.Text
style={[
styles.value,
displayAnim,
{ color: colors.displayText, fontSize },
]}
>
{value}
</Animated.Text>
{copied && (
<View style={[styles.badge, { backgroundColor: colors.accent }]}>
<Text style={{ color: colors.accentForeground }}>Copied</Text>
</View>
)}
</Pressable>
<View style={[styles.pad, { paddingBottom: bottom }]}>
<View style={styles.row}>
<CalcButton label="C" kind="func" onPress={() => dispatchAction({ type: "CLEAR" })} />
<CalcButton label="+/-" kind="func" onPress={() => dispatchAction({ type: "TOGGLE_SIGN" })} />
<CalcButton label="%" kind="func" onPress={() => dispatchAction({ type: "PERCENT" })} />
<CalcButton label="÷" kind="operator" onPress={() => dispatchAction({ type: "OPERATOR", value: "÷" })} />
</View>
<View style={styles.row}>
<CalcButton label="7" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "7" })} />
<CalcButton label="8" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "8" })} />
<CalcButton label="9" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "9" })} />
<CalcButton label="×" kind="operator" onPress={() => dispatchAction({ type: "OPERATOR", value: "×" })} />
</View>
<View style={styles.row}>
<CalcButton label="4" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "4" })} />
<CalcButton label="5" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "5" })} />
<CalcButton label="6" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "6" })} />
<CalcButton label="−" kind="operator" onPress={() => dispatchAction({ type: "OPERATOR", value: "−" })} />
</View>
<View style={styles.row}>
<CalcButton label="1" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "1" })} />
<CalcButton label="2" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "2" })} />
<CalcButton label="3" kind="number" onPress={() => dispatchAction({ type: "DIGIT", value: "3" })} />
<CalcButton label="+" kind="operator" onPress={() => dispatchAction({ type: "OPERATOR", value: "+" })} />
</View>
<View style={styles.row}>
<CalcButton
label="0"
kind="number"
wide
onPress={() => dispatchAction({ type: "DIGIT", value: "0" })}
/>
<CalcButton label="." kind="number" onPress={() => dispatchAction({ type: "DECIMAL" })} />
<CalcButton label="=" kind="equal" onPress={() => dispatchAction({ type: "EQUALS" })} />
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
display: {
flex: 1.5,
justifyContent: "flex-end",
alignItems: "flex-end",
paddingHorizontal: 24,
},
expr: { fontSize: 16, marginBottom: 6 },
value: { letterSpacing: -1, fontWeight: "300" },
pad: { paddingHorizontal: 20, paddingTop: 10 },
row: { flexDirection: "row", justifyContent: "center" },
button: { justifyContent: "center", alignItems: "center" },
text: { fontSize: 26, fontWeight: "600" },
badge: {
position: "absolute",
bottom: 10,
right: 12,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
},
});
import React, { useCallback, useReducer, useRef, useState, useEffect } from "react";
import {
Dimensions,
Platform,
Pressable,
StatusBar,
StyleSheet,
Text,
View,
useColorScheme,
} from "react-native";
import * as Clipboard from "expo-clipboard";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSequence,
withTiming,
} from "react-native-reanimated";
import * as Haptics from "expo-haptics";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useColors } from "@/hooks/useColors";
const { width } = Dimensions.get("window");
const BUTTON_SIZE = Math.floor((width - 64) / 4);
type CalcAction =
| { type: "DIGIT"; value: string }
| { type: "OPERATOR"; value: string }
| { type: "EQUALS" }
| { type: "CLEAR" }
| { type: "TOGGLE_SIGN" }
| { type: "PERCENT" }
| { type: "DECIMAL" }
| { type: "BACKSPACE" };
type CalcState = {
display: string;
expression: string;
operand: string | null;
operator: string | null;
waiting: boolean;
hasResult: boolean;
};
const initialState: CalcState = {
display: "0",
expression: "",
operand: null,
operator: null,
waiting: false,
hasResult: false,
};
function calculate(a: number, b: number, op: string): number {
switch (op) {
case "+":
return a + b;
case "−":
return a - b;
case "×":
return a * b;
case "÷":
return b === 0 ? NaN : a / b;
default:
return b;
}
}
function safeDisplay(value: string) {
if (value === "Error") return value;
const num = Number(value);
if (Number.isNaN(num)) return "Error";
if (!Number.isFinite(num)) return "Error";
if (Math.abs(num) > 1e15) return num.toExponential(6);
return value;
}
function reducer(state: CalcState, action: CalcAction): CalcState {
switch (action.type) {
case "DIGIT": {
if (state.hasResult) {
return { ...initialState, display: action.value };
}
}
}
type ButtonKind = "number" | "operator" | "equal" | "func";
function CalcButton({
label,
kind,
wide,
onPress,
}: {
label: string;
kind: ButtonKind;
wide?: boolean;
onPress: () => void;
}) {
const colors = useColors();
const scale = useSharedValue(1);
const anim = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const press = () => {
scale.value = withSequence(
withTiming(0.88, { duration: 80 }),
withTiming(1, { duration: 100 })
);
};
const bg =
kind === "number"
? colors.numberBtn
: kind === "operator"
? colors.operatorBtn
: kind === "equal"
? colors.equalBtn
: colors.funcBtn;
const color =
kind === "number"
? colors.numberText
: kind === "operator"
? colors.operatorText
: kind === "equal"
? colors.equalText
: colors.funcText;
return (
<Pressable onPress={press} style={{ margin: 4 }}>
<Animated.View
style={[
styles.button,
anim,
{
width: wide ? BUTTON_SIZE * 2 + 8 : BUTTON_SIZE,
height: BUTTON_SIZE,
backgroundColor: bg,
borderRadius: BUTTON_SIZE / 2,
},
]}
>
<Text style={[styles.text, { color }]}>{label}
</Animated.View>
);
}
export default function CalculatorScreen() {
const [state, dispatch] = useReducer(reducer, initialState);
const colors = useColors();
const insets = useSafeAreaInsets();
const isDark = useColorScheme() === "dark";
const scale = useSharedValue(1);
const displayAnim = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const animate = () => {
scale.value = withSequence(
withTiming(0.95, { duration: 60 }),
withTiming(1, { duration: 100 })
);
};
const dispatchAction = useCallback((action: CalcAction) => {
if (action.type === "OPERATOR" || action.type === "EQUALS") {
animate();
}
dispatch(action);
}, []);
const [copied, setCopied] = useState(false);
const timer = useRef<ReturnType | null>(null);
const copy = useCallback(async () => {
if (state.display === "Error") return;
}, [state.display]);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const value = safeDisplay(state.display);
const fontSize =
value.length > 12 ? 36 : value.length > 8 ? 48 : 64;
const top = insets.top + 20;
const bottom = insets.bottom + 16;
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<StatusBar barStyle={isDark ? "light-content" : "dark-content"} />
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
display: {
flex: 1.5,
justifyContent: "flex-end",
alignItems: "flex-end",
paddingHorizontal: 24,
},
expr: { fontSize: 16, marginBottom: 6 },
value: { letterSpacing: -1, fontWeight: "300" },
pad: { paddingHorizontal: 20, paddingTop: 10 },
row: { flexDirection: "row", justifyContent: "center" },
button: { justifyContent: "center", alignItems: "center" },
text: { fontSize: 26, fontWeight: "600" },
badge: {
position: "absolute",
bottom: 10,
right: 12,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 20,
},
});