From 38ae1aace9976890fc1db82db5a547f01c39fad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Feb 2026 09:48:00 +0000 Subject: [PATCH] Improve wallet UX with premium animations, haptics, and micro-interactions - Add AnimatedNumber component for smooth balance counting transitions - Add SkeletonLoader with shimmer effect replacing plain loading text - Add AnimatedPressable with spring animations and gesture handler - Add Toast notification system replacing Alert.alert for copy/share - Enhance WalletCard with 3D tilt, parallax decorative circles, perspective transforms, and wallet type pill badges - Enhance TransactionItem with staggered FadeInDown entrance animation, icon scale animation, relative time formatting, and directional slide feedback on press - Add haptic feedback throughout: pull-to-refresh, period selector, send/receive buttons, fee presets, send max, transaction success/error, address generation, balance toggle, and settings navigation - Upgrade tab animation with combined opacity fade + spring translateY for a premium screen transition feel - Integrate ToastProvider at root layout for app-wide toast support https://claude.ai/code/session_01U5QjrPEbaJHcb1gFwxU6Hf --- app/(tabs)/index.tsx | 43 ++-- app/(tabs)/receive.tsx | 26 ++- app/(tabs)/send.tsx | 8 + app/(tabs)/settings.tsx | 17 +- app/_layout.tsx | 5 +- components/AnimatedNumber.tsx | 77 +++++++ components/AnimatedPressable.tsx | 115 ++++++++++ components/SkeletonLoader.tsx | 271 +++++++++++++++++++++++ components/Toast.tsx | 217 ++++++++++++++++++ components/TransactionItem.tsx | 367 ++++++++++++++++--------------- components/WalletCard.tsx | 285 +++++++++++++++--------- hooks/use-tab-animation.ts | 30 ++- 12 files changed, 1136 insertions(+), 325 deletions(-) create mode 100644 components/AnimatedNumber.tsx create mode 100644 components/AnimatedPressable.tsx create mode 100644 components/SkeletonLoader.tsx create mode 100644 components/Toast.tsx diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 06953737..19cb44b5 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -2,12 +2,14 @@ import FeedbackPopup from '@/components/FeedbackPopup'; import { GradientBackground, GradientCard } from '@/components/GradientBackground'; import { LiquidGlassView } from '@/components/LiquidGlassView'; import BalanceChart from '@/components/PriceChart'; +import { HomeScreenSkeleton, TransactionListSkeleton } from '@/components/SkeletonLoader'; import TransactionItem from '@/components/TransactionItem'; import WalletCard from '@/components/WalletCard'; import { createButtonStyle, platformStyles } from '@/constants/themes'; import { WALLET_COLOR_PALETTE } from '@/constants/wallet-colors'; import { useTabAnimation } from '@/hooks/use-tab-animation'; import { useWallet } from '@/hooks/wallet-store'; +import { HapticService } from '@/services/haptic-service'; import { Wallet } from '@/types/wallet'; import { isIOS26OrHigher } from '@/utils/platform'; import { LinearGradient } from 'expo-linear-gradient'; @@ -96,10 +98,11 @@ function WalletScreenContent() { const carouselRef = useRef>(null); const onRefresh = useCallback(async () => { + HapticService.medium(); setRefreshing(true); await refreshData(); + HapticService.success(); setRefreshing(false); - // Track usage when user refreshes data incrementUsageCount('data_refresh'); }, [refreshData, incrementUsageCount]); @@ -232,20 +235,15 @@ function WalletScreenContent() { ); }, [theme.colors.surface, theme.colors.primary, currentWalletId, switchWallet, handleEditWallet, incrementUsageCount]); - // Show loading state while wallet is being loaded + // Show skeleton loading state while wallet is being loaded if (isLoading) { return ( - - - Loading Wallet... - - - Please wait while we load your wallet - - + + + ); @@ -403,9 +401,10 @@ function WalletScreenContent() { ) : ( <> - { + HapticService.light(); setHideBalanceSetting(!hideBalance); incrementUsageCount('settings_interaction'); }} @@ -463,6 +462,7 @@ function WalletScreenContent() { }, ]} onPress={() => { + HapticService.light(); setSelectedPeriod(period); incrementUsageCount('settings_interaction'); }} @@ -492,23 +492,31 @@ function WalletScreenContent() { {/* Action Buttons */} - router.push('/(tabs)/send')} + onPress={() => { + HapticService.medium(); + router.push('/(tabs)/send'); + }} + activeOpacity={0.85} > Send - - router.push('/(tabs)/receive')} + onPress={() => { + HapticService.medium(); + router.push('/(tabs)/receive'); + }} + activeOpacity={0.85} > Receive @@ -559,10 +567,11 @@ function WalletScreenContent() { ) : ( - transactions.slice(0, 5).map((transaction: any) => ( + transactions.slice(0, 5).map((transaction: any, idx: number) => ( )) )} diff --git a/app/(tabs)/receive.tsx b/app/(tabs)/receive.tsx index 083c7c29..3fe45c6b 100644 --- a/app/(tabs)/receive.tsx +++ b/app/(tabs)/receive.tsx @@ -1,9 +1,11 @@ import { GradientBackground } from '@/components/GradientBackground'; +import { toast } from '@/components/Toast'; import WalletSelector from '@/components/WalletSelector'; import { ADDRESS_GENERATION_COOLDOWN_MS, GAP_LIMIT_WARNING_THRESHOLD } from '@/constants/cache'; import { createButtonStyle, platformStyles } from '@/constants/themes'; import { useTabAnimation } from '@/hooks/use-tab-animation'; import { useWallet } from '@/hooks/wallet-store'; +import { HapticService } from '@/services/haptic-service'; import { loadWalletService } from '@/utils/wallet-service-loader'; import * as Clipboard from 'expo-clipboard'; import { Stack, router } from 'expo-router'; @@ -157,7 +159,8 @@ function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType 0 && currentAddress !== 'No address available'; const handleNewAddress = async () => { - if (isGeneratingAddress) return; // Prevent multiple simultaneous requests + if (isGeneratingAddress) return; + HapticService.medium(); // Rate limiting: Prevent spam and API abuse const now = Date.now(); @@ -220,43 +223,42 @@ function ReceiveScreenContent({ walletContext }: { walletContext: ReturnType { try { if (!hasValidAddress) { - Alert.alert('Error', 'No address available to copy'); + toast.error('No address available to copy'); return; } - + await Clipboard.setStringAsync(currentAddress); - Alert.alert('Copied', 'Address copied to clipboard'); + toast.success('Copied!', 'Address copied to clipboard'); } catch (error) { console.error('Error copying address:', error); - Alert.alert('Error', 'Failed to copy address to clipboard'); + toast.error('Copy failed', 'Could not copy address to clipboard'); } }; const handleShare = async () => { try { if (!hasValidAddress) { - Alert.alert('Error', 'No address available to share'); + toast.error('No address available to share'); return; } - - // Native sharing + + HapticService.light(); await Share.share({ message: `Bitcoin Address: ${currentAddress}`, title: 'Bitcoin Address', }); } catch (error) { console.error('Error sharing:', error); - // Fallback to copy try { if (hasValidAddress) { await Clipboard.setStringAsync(currentAddress); - Alert.alert('Copied', 'Address copied to clipboard instead'); + toast.info('Copied instead', 'Address copied to clipboard'); } else { - Alert.alert('Error', 'No address available'); + toast.error('No address available'); } } catch (clipboardError) { console.error('Clipboard error:', clipboardError); - Alert.alert('Error', 'Unable to share or copy address'); + toast.error('Share failed', 'Unable to share or copy address'); } } }; diff --git a/app/(tabs)/send.tsx b/app/(tabs)/send.tsx index b17c9bff..46f3f095 100644 --- a/app/(tabs)/send.tsx +++ b/app/(tabs)/send.tsx @@ -1,6 +1,7 @@ import { GradientBackground } from '@/components/GradientBackground'; import { LiquidGlassView } from '@/components/LiquidGlassView'; import QRScanner from '@/components/QRScanner'; +import { toast } from '@/components/Toast'; import { ThemedSwitch } from '@/components/ThemedSwitch'; import WalletSelector from '@/components/WalletSelector'; import { createButtonStyle, createInputStyle, platformStyles } from '@/constants/themes'; @@ -9,6 +10,7 @@ import { useTabAnimation } from '@/hooks/use-tab-animation'; import { useWallet } from '@/hooks/wallet-store'; import { isValidBitcoinAddress, sendTransaction } from '@/services/bitcoin-service'; import { feeEstimationService } from '@/services/fee-service'; +import { HapticService } from '@/services/haptic-service'; import type { UTXO } from '@/types/wallet'; import { Stack, router } from 'expo-router'; import { AlertCircle, ArrowUpRight, CheckCircle, ChevronRight, Coins, QrCode } from 'lucide-react-native'; @@ -106,6 +108,7 @@ function SendScreenContent() { // Memoize handlers to prevent recreation on every render const handleFeePresetChange = useCallback((preset: 'slow' | 'normal' | 'fast' | 'custom') => { + HapticService.light(); console.log(`🔧 Send screen: Fee preset changed to ${preset}`); console.log(`🔧 Current feeSettings.defaultPreset: ${feeSettings?.defaultPreset}`); @@ -353,6 +356,7 @@ function SendScreenContent() { }, [amount, feeRate]); const handleSendMax = useCallback(() => { + HapticService.light(); try { if (balance > 0) { // Calculate more accurate fee estimate @@ -700,6 +704,7 @@ function SendScreenContent() { ); console.log('✅ Real Bitcoin transaction sent successfully:', result); + HapticService.transactionSuccess(); // Show success message with transaction details const feeFiat = bitcoinPrice && bitcoinPrice > 0 ? (result.fee * bitcoinPrice).toFixed(2) : 'N/A'; @@ -771,6 +776,7 @@ function SendScreenContent() { } const errorMessageText = `Failed to send Bitcoin transaction:\n\n${userMessage}\n\nPlease check your inputs and try again.`; + HapticService.transactionError(); Alert.alert( 'Transaction Failed', errorMessageText, @@ -782,9 +788,11 @@ function SendScreenContent() { }; const handleReviewTransaction = () => { + HapticService.medium(); try { // Validate inputs if (!recipientAddress.trim()) { + HapticService.warning(); Alert.alert('Error', 'Please enter a recipient address'); return; } diff --git a/app/(tabs)/settings.tsx b/app/(tabs)/settings.tsx index 9b9906ae..bc871e5c 100644 --- a/app/(tabs)/settings.tsx +++ b/app/(tabs)/settings.tsx @@ -157,11 +157,11 @@ function SettingsScreenContent() { logoutScale.value = withSpring(1, { damping: 15, stiffness: 400 }); }; - const SettingItem = ({ - icon: Icon, - title, - subtitle, - onPress, + const SettingItem = ({ + icon: Icon, + title, + subtitle, + onPress, rightElement, iconColor = theme.colors.primary, showDivider = true, @@ -177,7 +177,12 @@ function SettingsScreenContent() { <> { + if (onPress) { + HapticService.light(); + onPress(); + } + }} disabled={!onPress} activeOpacity={onPress ? 0.7 : 1} > diff --git a/app/_layout.tsx b/app/_layout.tsx index 0b3705ac..4162c867 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -10,6 +10,7 @@ import { initializeNetworkingPolyfill } from '../services/networking-polyfill'; import ActivityTracker from '@/components/ActivityTracker'; import PinUnlockScreen from '@/components/PinUnlockScreen'; import SplashScreen from '@/components/SplashScreen'; +import { ToastProvider } from '@/components/Toast'; import { platformStyles } from '@/constants/themes'; import { AutoLockProvider, useAutoLock } from '@/hooks/auto-lock-store'; import { useSplashScreen } from '@/hooks/use-splash-screen'; @@ -353,7 +354,9 @@ function RootLayoutNav() { - + + + diff --git a/components/AnimatedNumber.tsx b/components/AnimatedNumber.tsx new file mode 100644 index 00000000..ed43c9aa --- /dev/null +++ b/components/AnimatedNumber.tsx @@ -0,0 +1,77 @@ +import React, { useEffect } from 'react'; +import { StyleProp, TextStyle } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, + useDerivedValue, + useAnimatedProps, + runOnJS, +} from 'react-native-reanimated'; +import { TextInput } from 'react-native'; + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); + +interface AnimatedNumberProps { + value: number; + formatter?: (value: number) => string; + style?: StyleProp; + duration?: number; + prefix?: string; + suffix?: string; +} + +/** + * AnimatedNumber - Smoothly animates between numeric values with a counting effect. + * Used for balance displays, price tickers, and any numeric value that changes over time. + * Creates a premium feel by making value changes feel alive rather than instant. + */ +export default function AnimatedNumber({ + value, + formatter, + style, + duration = 800, + prefix = '', + suffix = '', +}: AnimatedNumberProps) { + const animatedValue = useSharedValue(value); + + useEffect(() => { + animatedValue.value = withTiming(value, { duration }); + }, [value, duration, animatedValue]); + + // Scale animation on value change for emphasis + const scale = useSharedValue(1); + + useEffect(() => { + scale.value = withSpring(1.02, { damping: 8, stiffness: 300 }, () => { + scale.value = withSpring(1, { damping: 15, stiffness: 400 }); + }); + }, [value, scale]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + const format = formatter || ((v: number) => v.toFixed(2)); + + const animatedProps = useAnimatedProps(() => { + const text = `${prefix}${format(animatedValue.value)}${suffix}`; + return { + text, + defaultValue: text, + }; + }); + + return ( + + + + ); +} diff --git a/components/AnimatedPressable.tsx b/components/AnimatedPressable.tsx new file mode 100644 index 00000000..a31a20bf --- /dev/null +++ b/components/AnimatedPressable.tsx @@ -0,0 +1,115 @@ +import { HapticService } from '@/services/haptic-service'; +import React, { useCallback } from 'react'; +import { StyleProp, ViewStyle } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + runOnJS, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; + +type HapticType = 'light' | 'medium' | 'heavy' | 'none'; + +interface AnimatedPressableProps { + children: React.ReactNode; + onPress?: () => void; + onLongPress?: () => void; + style?: StyleProp; + scaleDown?: number; + haptic?: HapticType; + disabled?: boolean; +} + +const SPRING_CONFIG = { damping: 15, stiffness: 400 }; + +/** + * AnimatedPressable - A universal pressable component with spring animations and haptics. + * Provides consistent interaction feedback across the entire app. + * Uses Gesture Handler for smoother gesture recognition. + */ +export default function AnimatedPressable({ + children, + onPress, + onLongPress, + style, + scaleDown = 0.97, + haptic = 'light', + disabled = false, +}: AnimatedPressableProps) { + const scale = useSharedValue(1); + const opacity = useSharedValue(1); + + const triggerHaptic = useCallback((type: HapticType) => { + if (type === 'none') return; + switch (type) { + case 'light': + HapticService.light(); + break; + case 'medium': + HapticService.medium(); + break; + case 'heavy': + HapticService.heavy(); + break; + } + }, []); + + const handlePress = useCallback(() => { + if (!disabled && onPress) { + onPress(); + } + }, [disabled, onPress]); + + const handleLongPress = useCallback(() => { + if (!disabled && onLongPress) { + onLongPress(); + } + }, [disabled, onLongPress]); + + const tapGesture = Gesture.Tap() + .enabled(!disabled) + .onBegin(() => { + 'worklet'; + scale.value = withSpring(scaleDown, SPRING_CONFIG); + opacity.value = withSpring(0.9, SPRING_CONFIG); + if (haptic !== 'none') { + runOnJS(triggerHaptic)(haptic); + } + }) + .onEnd(() => { + 'worklet'; + runOnJS(handlePress)(); + }) + .onFinalize(() => { + 'worklet'; + scale.value = withSpring(1, SPRING_CONFIG); + opacity.value = withSpring(1, SPRING_CONFIG); + }); + + const longPressGesture = Gesture.LongPress() + .enabled(!disabled && !!onLongPress) + .minDuration(500) + .onStart(() => { + 'worklet'; + runOnJS(triggerHaptic)('medium'); + runOnJS(handleLongPress)(); + }); + + const composedGesture = onLongPress + ? Gesture.Race(tapGesture, longPressGesture) + : tapGesture; + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + opacity: disabled ? 0.5 : opacity.value, + })); + + return ( + + + {children} + + + ); +} diff --git a/components/SkeletonLoader.tsx b/components/SkeletonLoader.tsx new file mode 100644 index 00000000..3678eb85 --- /dev/null +++ b/components/SkeletonLoader.tsx @@ -0,0 +1,271 @@ +import { platformStyles } from '@/constants/themes'; +import { useWallet } from '@/hooks/wallet-store'; +import React, { useEffect } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + Easing, + interpolate, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +interface SkeletonProps { + width: number | string; + height: number; + borderRadius?: number; + style?: any; +} + +/** + * Skeleton - A shimmer loading placeholder that creates a premium loading experience. + * Replaces plain "Loading..." text with animated shimmer bars that match the layout. + */ +function Skeleton({ width, height, borderRadius = 8, style }: SkeletonProps) { + const walletContext = useWallet(); + const theme = walletContext?.theme; + const shimmer = useSharedValue(0); + + useEffect(() => { + shimmer.value = withRepeat( + withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }), + -1, + true + ); + }, [shimmer]); + + const animatedStyle = useAnimatedStyle(() => { + const opacity = interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.7, 0.3]); + return { opacity }; + }); + + const baseColor = theme?.isDark ? '#27272A' : '#E5E7EB'; + + return ( + + ); +} + +/** + * WalletCardSkeleton - Skeleton placeholder for the wallet card carousel. + */ +export function WalletCardSkeleton() { + const walletContext = useWallet(); + const theme = walletContext?.theme; + + return ( + + + + + + + + + + + + + + ); +} + +/** + * BalanceSkeleton - Skeleton placeholder for the balance display area. + */ +export function BalanceSkeleton() { + return ( + + + + + + ); +} + +/** + * TransactionSkeleton - Skeleton placeholder for a single transaction item. + */ +export function TransactionSkeleton() { + const walletContext = useWallet(); + const theme = walletContext?.theme; + + return ( + + + + + + + + + + + + + + + + + ); +} + +/** + * TransactionListSkeleton - Multiple transaction skeletons for list loading state. + */ +export function TransactionListSkeleton({ count = 3 }: { count?: number }) { + return ( + + {Array.from({ length: count }).map((_, i) => ( + + ))} + + ); +} + +/** + * ChartSkeleton - Skeleton placeholder for the price chart area. + */ +export function ChartSkeleton() { + const walletContext = useWallet(); + const theme = walletContext?.theme; + + return ( + + + + ); +} + +/** + * HomeScreenSkeleton - Full skeleton for the home screen loading state. + */ +export function HomeScreenSkeleton() { + return ( + + {/* Header skeleton */} + + + + + + + + + + + + + + + {/* Wallet card skeleton */} + + + {/* Balance skeleton */} + + + {/* Period selector skeleton */} + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + + {/* Chart skeleton */} + + + {/* Action buttons skeleton */} + + + + + + {/* Transaction list skeleton */} + + + ); +} + +const skeletonStyles = StyleSheet.create({ + homeContainer: { + padding: platformStyles.spacing.xl, + gap: platformStyles.spacing.xl, + }, + headerSkeleton: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + paddingTop: platformStyles.spacing.xl, + }, + priceSkeletonContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + walletCard: { + width: 340, + height: 200, + borderRadius: platformStyles.borderRadius.xxxl, + padding: platformStyles.spacing.xxl, + justifyContent: 'space-between', + }, + walletCardHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + }, + walletCardBody: { + flex: 1, + justifyContent: 'center', + }, + walletCardFooter: { + alignItems: 'flex-end', + }, + balanceContainer: { + alignItems: 'center', + paddingVertical: platformStyles.spacing.xl, + }, + transactionItem: { + flexDirection: 'row', + padding: platformStyles.spacing.xl, + marginVertical: platformStyles.spacing.sm, + borderRadius: platformStyles.borderRadius.xl, + alignItems: 'flex-start', + }, + transactionContent: { + flex: 1, + marginLeft: platformStyles.spacing.lg, + }, + transactionRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + periodSkeleton: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: platformStyles.spacing.lg, + }, + chartContainer: { + borderRadius: platformStyles.borderRadius.xxl, + padding: platformStyles.spacing.lg, + overflow: 'hidden', + }, + actionSkeleton: { + flexDirection: 'row', + justifyContent: 'space-between', + }, +}); + +export default Skeleton; diff --git a/components/Toast.tsx b/components/Toast.tsx new file mode 100644 index 00000000..4b73be6d --- /dev/null +++ b/components/Toast.tsx @@ -0,0 +1,217 @@ +import { platformStyles } from '@/constants/themes'; +import { useWallet } from '@/hooks/wallet-store'; +import { HapticService } from '@/services/haptic-service'; +import { CheckCircle, Info, AlertTriangle, XCircle } from 'lucide-react-native'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import Animated, { + Easing, + runOnJS, + useAnimatedStyle, + useSharedValue, + withDelay, + withSpring, + withTiming, +} from 'react-native-reanimated'; + +type ToastType = 'success' | 'error' | 'warning' | 'info'; + +interface ToastMessage { + id: string; + type: ToastType; + title: string; + message?: string; + duration?: number; +} + +// Global toast queue +let toastListeners: ((toast: ToastMessage) => void)[] = []; + +/** + * Show a toast notification from anywhere in the app. + * Call toast.success('Title') or toast.show({ type: 'success', title: 'Title' }) + */ +export const toast = { + show: (config: Omit) => { + const message: ToastMessage = { + ...config, + id: Date.now().toString() + Math.random().toString(36).slice(2), + }; + toastListeners.forEach(listener => listener(message)); + }, + success: (title: string, message?: string) => { + HapticService.success(); + toast.show({ type: 'success', title, message }); + }, + error: (title: string, message?: string) => { + HapticService.error(); + toast.show({ type: 'error', title, message }); + }, + warning: (title: string, message?: string) => { + HapticService.warning(); + toast.show({ type: 'warning', title, message }); + }, + info: (title: string, message?: string) => { + HapticService.light(); + toast.show({ type: 'info', title, message }); + }, +}; + +const ICON_MAP = { + success: CheckCircle, + error: XCircle, + warning: AlertTriangle, + info: Info, +}; + +function ToastItem({ item, onDismiss }: { item: ToastMessage; onDismiss: (id: string) => void }) { + const walletContext = useWallet(); + const theme = walletContext?.theme; + + const translateY = useSharedValue(-100); + const opacity = useSharedValue(0); + const scale = useSharedValue(0.9); + + useEffect(() => { + // Animate in + translateY.value = withSpring(0, { damping: 20, stiffness: 300 }); + opacity.value = withTiming(1, { duration: 200 }); + scale.value = withSpring(1, { damping: 15, stiffness: 400 }); + + // Animate out after duration + const duration = item.duration || 2500; + const timeout = setTimeout(() => { + translateY.value = withTiming(-100, { duration: 300, easing: Easing.in(Easing.ease) }); + opacity.value = withTiming(0, { duration: 300 }, () => { + runOnJS(onDismiss)(item.id); + }); + scale.value = withTiming(0.9, { duration: 300 }); + }, duration); + + return () => clearTimeout(timeout); + }, [item.id, item.duration, translateY, opacity, scale, onDismiss]); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateY: translateY.value }, + { scale: scale.value }, + ], + opacity: opacity.value, + })); + + const Icon = ICON_MAP[item.type]; + const iconColor = item.type === 'success' ? (theme?.colors.success || '#10B981') + : item.type === 'error' ? (theme?.colors.error || '#EF4444') + : item.type === 'warning' ? (theme?.colors.warning || '#F59E0B') + : (theme?.colors.primary || '#F7931A'); + + return ( + + + + + + + {item.title} + + {item.message && ( + + {item.message} + + )} + + + ); +} + +/** + * ToastProvider - Renders toast notifications at the top of the screen. + * Place this component near the root of your app. + */ +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + useEffect(() => { + const listener = (message: ToastMessage) => { + setToasts(prev => [...prev.slice(-2), message]); // Keep max 3 toasts + }; + toastListeners.push(listener); + return () => { + toastListeners = toastListeners.filter(l => l !== listener); + }; + }, []); + + const handleDismiss = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)); + }, []); + + return ( + + {children} + + {toasts.map(item => ( + + ))} + + + ); +} + +const styles = StyleSheet.create({ + wrapper: { + flex: 1, + }, + toastContainer: { + position: 'absolute', + top: 60, + left: 0, + right: 0, + alignItems: 'center', + zIndex: 9999, + }, + toastItem: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + marginHorizontal: platformStyles.spacing.xl, + paddingHorizontal: platformStyles.spacing.lg, + paddingVertical: platformStyles.spacing.md, + borderRadius: platformStyles.borderRadius.xl, + borderLeftWidth: 4, + ...platformStyles.cardShadow, + maxWidth: 400, + width: '90%', + }, + iconContainer: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + marginRight: platformStyles.spacing.md, + }, + textContainer: { + flex: 1, + }, + title: { + fontSize: 15, + fontWeight: '700', + letterSpacing: 0.1, + }, + message: { + fontSize: 13, + marginTop: 2, + lineHeight: 18, + }, +}); + +export default ToastProvider; diff --git a/components/TransactionItem.tsx b/components/TransactionItem.tsx index 21c5b07e..1db414dd 100644 --- a/components/TransactionItem.tsx +++ b/components/TransactionItem.tsx @@ -4,68 +4,86 @@ import { HapticService } from '@/services/haptic-service'; import { Transaction } from '@/types/wallet'; import { router } from 'expo-router'; import { ArrowDownLeft, ArrowUpRight, CheckCircle, Clock, DollarSign, Zap } from 'lucide-react-native'; -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Animated, { - useAnimatedStyle, - useSharedValue, - withSequence, - withSpring, - withTiming, + Easing, + FadeInDown, + useAnimatedStyle, + useSharedValue, + withSequence, + withSpring, + withTiming, } from 'react-native-reanimated'; const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); interface TransactionItemProps { transaction: Transaction; + index?: number; } // Wrapper component that checks for context availability -export default function TransactionItem({ transaction }: TransactionItemProps) { +export default function TransactionItem({ transaction, index = 0 }: TransactionItemProps) { const walletContext = useWallet(); - - // Safety check: if context is not available yet, return null + if (!walletContext) { return null; } - - return ; + + return ; } -// Main component with all hooks -function TransactionItemContent({ transaction }: TransactionItemProps) { - const { theme, bitcoinPrice, hasPriceError, formatCurrency } = useWallet()!; // Non-null assertion is safe here because wrapper checked - +function TransactionItemContent({ transaction, index = 0 }: TransactionItemProps) { + const { theme, bitcoinPrice, hasPriceError, formatCurrency } = useWallet()!; + const isReceived = transaction.type === 'received'; const amountUSD = !hasPriceError && bitcoinPrice?.usd ? transaction.amount * bitcoinPrice.usd : 0; - + // Animation values const scale = useSharedValue(1); const translateX = useSharedValue(0); - const opacity = useSharedValue(1); + + // Icon entrance animation + const iconScale = useSharedValue(0); + useEffect(() => { + iconScale.value = withSpring(1, { damping: 12, stiffness: 200, mass: 0.8 }); + }, [iconScale]); + + const iconAnimatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: iconScale.value }], + })); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { scale: scale.value }, { translateX: translateX.value }, ], - opacity: opacity.value, })); - + const formatDate = (timestamp: number) => { const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, }); }; const truncateAddress = (address: string) => { if (!address) return ''; - return `${address.slice(0, 8)}...${address.slice(-8)}`; + return `${address.slice(0, 6)}...${address.slice(-6)}`; }; const handlePressIn = useCallback(() => { @@ -79,10 +97,10 @@ function TransactionItemContent({ transaction }: TransactionItemProps) { const handlePress = useCallback(() => { HapticService.medium(); - - // Subtle slide animation on press + + const direction = isReceived ? -4 : 4; translateX.value = withSequence( - withTiming(4, { duration: 100 }), + withTiming(direction, { duration: 100, easing: Easing.out(Easing.ease) }), withSpring(0, { damping: 15, stiffness: 300 }) ); @@ -90,14 +108,7 @@ function TransactionItemContent({ transaction }: TransactionItemProps) { pathname: '/transaction-explorer', params: { txid: transaction.txid }, }); - }, [translateX, transaction.txid]); - - const getStatusIcon = () => { - if (transaction.status === 'confirmed') { - return ; - } - return ; - }; + }, [translateX, transaction.txid, isReceived]); const getStatusColor = () => { if (transaction.status === 'confirmed') { @@ -106,132 +117,143 @@ function TransactionItemContent({ transaction }: TransactionItemProps) { return theme.colors.warning; }; + // Staggered entrance animation + const enteringAnimation = FadeInDown.delay(index * 60).duration(400).springify().damping(15); + return ( - - - {isReceived ? ( - - ) : ( - - )} - + + + {/* Transaction direction icon */} + + {isReceived ? ( + + ) : ( + + )} + - - - + + {/* Top row: Type + Amount */} + {isReceived ? 'Received' : 'Sent'} - - - {isReceived ? '+' : '-'}{transaction.amount.toFixed(8)} BTC - - - - - - {formatDate(transaction.timestamp)} - - {!hasPriceError && amountUSD > 0 ? ( - - {isReceived ? '+' : '-'}{formatCurrency(amountUSD)} - - ) : ( - - Fiat unavailable + + {isReceived ? '+' : '-'}{transaction.amount.toFixed(8)} BTC - )} - + - - - {getStatusIcon()} - - {transaction.status === 'confirmed' ? 'Completed' : 'Pending'} + {/* Middle row: Date + USD amount */} + + + {formatDate(transaction.timestamp)} - - - {/* RBF and CPFP Indicators */} - - {transaction.rbf && ( - - - - RBF - - - )} - {transaction.cpfp && ( - - - - CPFP - - - )} - {transaction.childTxids && transaction.childTxids.length > 0 && ( - - - - Parent - - + {!hasPriceError && amountUSD > 0 ? ( + + {isReceived ? '+' : '-'}{formatCurrency(amountUSD)} + + ) : ( + + Fiat unavailable + )} - - - {isReceived ? 'From' : 'To'}: {truncateAddress(transaction.address)} - - - + {/* Bottom row: Status + Features */} + + + {transaction.status === 'confirmed' ? ( + + ) : ( + + )} + + {transaction.status === 'confirmed' ? 'Confirmed' : 'Pending'} + + + + {/* Feature indicators */} + + {transaction.rbf && ( + + + RBF + + )} + {transaction.cpfp && ( + + + CPFP + + )} + {transaction.childTxids && transaction.childTxids.length > 0 && ( + + + Parent + + )} + + + + {/* Address */} + + {isReceived ? 'From' : 'To'}: {truncateAddress(transaction.address)} + + + + ); } const styles = StyleSheet.create({ container: { flexDirection: 'row', - padding: platformStyles.spacing.xl, // Increased from lg - marginVertical: platformStyles.spacing.sm, // Increased from xs + padding: platformStyles.spacing.lg, + marginVertical: platformStyles.spacing.xs, marginHorizontal: platformStyles.spacing.xs, - borderRadius: platformStyles.borderRadius.xl, // Increased from large + borderRadius: platformStyles.borderRadius.xl, alignItems: 'flex-start', ...platformStyles.shadow, position: 'relative', overflow: 'hidden', }, iconContainer: { - width: 48, // Increased from 44 - height: 48, - borderRadius: 24, + width: 44, + height: 44, + borderRadius: 14, justifyContent: 'center', alignItems: 'center', - marginRight: platformStyles.spacing.lg, // Increased from md - ...platformStyles.shadow, + marginRight: platformStyles.spacing.md, }, content: { flex: 1, @@ -240,87 +262,82 @@ const styles = StyleSheet.create({ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: platformStyles.spacing.sm, // Increased from xs - }, - typeContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, // Increased from 6 + marginBottom: 4, }, type: { - fontSize: 17, // Increased from 16 - lineHeight: 22, // Increased from 20 - fontWeight: '700', // Increased from 600 + fontSize: 16, + lineHeight: 22, + fontWeight: '700', letterSpacing: 0.1, }, amount: { - fontSize: 17, // Increased from 16 - lineHeight: 22, // Increased from 20 - fontWeight: '700', // Increased from 600 - letterSpacing: 0.1, + fontSize: 16, + lineHeight: 22, + fontWeight: '700', + letterSpacing: -0.2, }, details: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: platformStyles.spacing.md, // Increased from sm + marginBottom: platformStyles.spacing.sm, }, date: { - fontSize: 14, // Increased from 13 - lineHeight: 18, // Increased from 16 + fontSize: 13, + lineHeight: 18, fontWeight: '500', letterSpacing: 0.2, }, amountUSD: { - fontSize: 14, // Increased from 13 - lineHeight: 18, // Increased from 16 - fontWeight: '600', // Increased from 500 + fontSize: 13, + lineHeight: 18, + fontWeight: '600', letterSpacing: 0.2, }, statusRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: platformStyles.spacing.sm, // Increased from xs + marginBottom: 4, }, statusBadge: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: platformStyles.spacing.md + 2, // Increased - paddingVertical: platformStyles.spacing.xs + 3, // Increased - borderRadius: platformStyles.borderRadius.large, // Increased from medium - gap: 5, // Increased from 4 + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 8, + gap: 4, }, statusText: { - color: 'white', - fontSize: 13, // Increased from 12 - lineHeight: 16, - fontWeight: '700', // Increased from 600 + fontSize: 11, + lineHeight: 14, + fontWeight: '700', letterSpacing: 0.3, }, featureIndicators: { flexDirection: 'row', - gap: 6, // Increased from 4 + gap: 4, }, featureBadge: { flexDirection: 'row', alignItems: 'center', - paddingHorizontal: 8, // Increased from 6 - paddingVertical: 4, // Increased from 3 - borderRadius: 10, // Increased from 8 - gap: 3, // Increased from 2 + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 6, + gap: 2, }, featureText: { - fontSize: 11, // Increased from 10 - lineHeight: 14, // Increased from 12 - fontWeight: '700', // Increased from 600 + fontSize: 10, + lineHeight: 12, + fontWeight: '700', letterSpacing: 0.2, }, address: { - fontSize: 13, // Increased from 12 - lineHeight: 18, // Increased from 16 - marginTop: platformStyles.spacing.sm, // Increased from xs + fontSize: 12, + lineHeight: 16, + marginTop: 2, fontWeight: '500', - letterSpacing: 0.2, + letterSpacing: 0.3, + fontFamily: 'monospace', }, -}); \ No newline at end of file +}); diff --git a/components/WalletCard.tsx b/components/WalletCard.tsx index 32a3bc2f..8e9e054b 100644 --- a/components/WalletCard.tsx +++ b/components/WalletCard.tsx @@ -4,21 +4,22 @@ import { useWallet } from '@/hooks/wallet-store'; import { HapticService } from '@/services/haptic-service'; import { Wallet, getWalletTypeDisplayName } from '@/types/wallet'; import { LinearGradient } from 'expo-linear-gradient'; -import { Check, Edit3, MoreHorizontal, Trash2 } from 'lucide-react-native'; +import { Edit3, MoreHorizontal, Trash2 } from 'lucide-react-native'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSequence, - withSpring + withSpring, + withTiming, } from 'react-native-reanimated'; const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity); const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient); // Default gradient colors for fallback -const DEFAULT_GRADIENT = ['#6366F1', '#8B5CF6'] as const; // Indigo gradient +const DEFAULT_GRADIENT = ['#6366F1', '#8B5CF6'] as const; interface WalletCardProps { wallet?: Wallet; @@ -30,90 +31,106 @@ interface WalletCardProps { // Wrapper component that checks for context availability export default function WalletCard({ wallet, isActive = false, onPress, onEdit }: WalletCardProps) { const walletContext = useWallet(); - - // Safety check: if context is not available yet, return null or loading + if (!walletContext) { return null; } - + return ; } -// Main component with all hooks function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: WalletCardProps) { - const { currentWallet, balance, balanceUSD, hasBalanceError, hasPriceError, formatCurrency, hideBalance, deleteWallet, theme } = useWallet()!; // Non-null assertion is safe here because wrapper checked - - // Initialize all hooks + const { currentWallet, balance, balanceUSD, hasBalanceError, hasPriceError, formatCurrency, hideBalance, deleteWallet, theme } = useWallet()!; + const [showMenu, setShowMenu] = useState(false); const [menuPosition, setMenuPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); const menuButtonRef = useRef(null); // Animation values const scale = useSharedValue(1); - const elevation = useSharedValue(isActive ? 8 : 4); + const rotateX = useSharedValue(0); + const rotateY = useSharedValue(0); const glowOpacity = useSharedValue(isActive ? 0.3 : 0); + // Decorative circles parallax + const circleTranslateX = useSharedValue(0); + const circleTranslateY = useSharedValue(0); const animatedCardStyle = useAnimatedStyle(() => ({ - transform: [{ scale: scale.value }], - shadowOpacity: 0.2 + (glowOpacity.value * 0.3), + transform: [ + { perspective: 800 }, + { scale: scale.value }, + { rotateX: `${rotateX.value}deg` }, + { rotateY: `${rotateY.value}deg` }, + ], })); const animatedGlowStyle = useAnimatedStyle(() => ({ opacity: glowOpacity.value, })); - // Use provided wallet or fall back to current wallet + // Parallax effect for decorative circles + const animatedCircle1Style = useAnimatedStyle(() => ({ + transform: [ + { translateX: circleTranslateX.value * 0.8 }, + { translateY: circleTranslateY.value * 0.8 }, + ], + })); + + const animatedCircle2Style = useAnimatedStyle(() => ({ + transform: [ + { translateX: -circleTranslateX.value * 0.5 }, + { translateY: -circleTranslateY.value * 0.5 }, + ], + })); + const displayWallet = wallet || currentWallet; - - // Memoize gradient colors to prevent recalculation on every render - // Only depends on color property to avoid unnecessary recalculations + // eslint-disable-next-line react-hooks/exhaustive-deps const gradientColors = useMemo(() => displayWallet ? getWalletGradient(displayWallet.color) : DEFAULT_GRADIENT, [displayWallet?.color]); - // Update glow effect when active state changes React.useEffect(() => { glowOpacity.value = withSpring(isActive ? 0.4 : 0, { damping: 15, stiffness: 200 }); - elevation.value = withSpring(isActive ? 12 : 4, { damping: 15, stiffness: 200 }); - }, [isActive, glowOpacity, elevation]); + }, [isActive, glowOpacity]); - // Menu button press handler const handleMenuPress = useCallback(() => { + HapticService.light(); menuButtonRef.current?.measure((x: number, y: number, width: number, height: number, pageX: number, pageY: number) => { const menuWidth = 150; const padding = 20; - - // Always position menu to the left of the button with proper spacing const menuX = pageX - menuWidth + width - padding; - - setMenuPosition({ - x: Math.max(padding, menuX), - y: pageY + height + 5 + + setMenuPosition({ + x: Math.max(padding, menuX), + y: pageY + height + 5 }); setShowMenu(true); }); }, []); - // Edit button press handler const handleEditPress = useCallback(() => { setShowMenu(false); + HapticService.light(); if (onEdit && displayWallet) { onEdit(displayWallet); } }, [onEdit, displayWallet]); - // Delete button press handler const handleDeletePress = useCallback(() => { setShowMenu(false); + HapticService.warning(); if (displayWallet) { Alert.alert( 'Delete Wallet', `Are you sure you want to delete "${displayWallet.name}"? This action cannot be undone.`, [ { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', + { + text: 'Delete', style: 'destructive', - onPress: () => deleteWallet(displayWallet.id) + onPress: () => { + HapticService.error(); + deleteWallet(displayWallet.id); + } } ] ); @@ -122,43 +139,55 @@ function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: Wallet const handlePressIn = useCallback(() => { HapticService.light(); - scale.value = withSpring(0.97, { damping: 15, stiffness: 400 }); - }, [scale]); + scale.value = withSpring(0.96, { damping: 12, stiffness: 400 }); + // Subtle 3D tilt on press + rotateX.value = withSpring(2, { damping: 15, stiffness: 300 }); + circleTranslateX.value = withSpring(3, { damping: 15, stiffness: 300 }); + circleTranslateY.value = withSpring(2, { damping: 15, stiffness: 300 }); + }, [scale, rotateX, circleTranslateX, circleTranslateY]); const handlePressOut = useCallback(() => { scale.value = withSpring(1, { damping: 15, stiffness: 400 }); - }, [scale]); + rotateX.value = withSpring(0, { damping: 15, stiffness: 300 }); + rotateY.value = withSpring(0, { damping: 15, stiffness: 300 }); + circleTranslateX.value = withSpring(0, { damping: 15, stiffness: 300 }); + circleTranslateY.value = withSpring(0, { damping: 15, stiffness: 300 }); + }, [scale, rotateX, rotateY, circleTranslateX, circleTranslateY]); const handlePress = useCallback(() => { if (onPress) { HapticService.medium(); - // Subtle bounce animation scale.value = withSequence( - withSpring(1.02, { damping: 10, stiffness: 300 }), + withSpring(1.03, { damping: 8, stiffness: 300 }), withSpring(1, { damping: 15, stiffness: 400 }) ); + // Quick tilt bounce on selection + rotateY.value = withSequence( + withTiming(3, { duration: 100 }), + withSpring(0, { damping: 12, stiffness: 300 }) + ); onPress(); } - }, [onPress, scale]); - + }, [onPress, scale, rotateY]); + if (!displayWallet) return null; return ( - {/* Glow effect for active card */} {isActive && ( - )} - {/* Decorative elements */} - - - + {/* Animated decorative elements with parallax */} + + + {/* Additional decorative element for depth */} + + - {displayWallet.name} + {displayWallet.name} - {getWalletTypeDisplayName(displayWallet.type)} + + {getWalletTypeDisplayName(displayWallet.type)} + - - + @@ -201,7 +235,9 @@ function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: Wallet ) : ( <> - {balance.toFixed(8)} BTC + + {balance.toFixed(8)} BTC + {hasPriceError ? 'Fiat value unavailable' : formatCurrency(balanceUSD)} @@ -212,22 +248,22 @@ function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: Wallet {isActive && ( - + Active )} - + setShowMenu(false)} > - setShowMenu(false)} > - - - Edit + + + + Edit - - + + - + + + Delete @@ -262,10 +304,10 @@ function WalletCardContent({ wallet, isActive = false, onPress, onEdit }: Wallet const styles = StyleSheet.create({ card: { - borderRadius: platformStyles.borderRadius.xxxl, // Increased from xxl - padding: platformStyles.spacing.xxl, // Increased from xl - width: 340, // Increased from 320 for better content space - height: 200, // Increased from 180 for better proportions + borderRadius: platformStyles.borderRadius.xxxl, + padding: platformStyles.spacing.xxl, + width: 340, + height: 200, justifyContent: 'space-between', position: 'relative', overflow: 'hidden', @@ -290,7 +332,7 @@ const styles = StyleSheet.create({ width: 100, height: 100, borderRadius: 50, - backgroundColor: 'rgba(255, 255, 255, 0.08)', + backgroundColor: 'rgba(255, 255, 255, 0.1)', }, decorativeCircle2: { position: 'absolute', @@ -299,7 +341,16 @@ const styles = StyleSheet.create({ width: 120, height: 120, borderRadius: 60, - backgroundColor: 'rgba(255, 255, 255, 0.06)', + backgroundColor: 'rgba(255, 255, 255, 0.07)', + }, + decorativeCircle3: { + position: 'absolute', + top: 60, + right: 40, + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: 'rgba(255, 255, 255, 0.04)', }, header: { flexDirection: 'row', @@ -312,28 +363,35 @@ const styles = StyleSheet.create({ }, walletName: { color: 'white', - fontSize: 22, // Increased from 20 - fontWeight: '800', // Increased from 700 - maxWidth: 220, // Increased from 200 - textShadowColor: 'rgba(0, 0, 0, 0.4)', // Slightly stronger shadow + fontSize: 22, + fontWeight: '800', + maxWidth: 220, + textShadowColor: 'rgba(0, 0, 0, 0.4)', textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 4, // Increased from 3 + textShadowRadius: 4, letterSpacing: 0.2, }, walletTypeContainer: { flexDirection: 'row', alignItems: 'center', - marginTop: platformStyles.spacing.sm, // Increased from xs - gap: 8, // Increased from 6 + marginTop: platformStyles.spacing.sm, + gap: 8, + }, + walletTypePill: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + paddingHorizontal: 10, + paddingVertical: 3, + borderRadius: 10, }, walletType: { - color: 'rgba(255, 255, 255, 0.9)', // Increased opacity from 0.85 - fontSize: 14, // Increased from 13 - fontWeight: '600', // Increased from 500 - letterSpacing: 0.3, + color: 'rgba(255, 255, 255, 0.95)', + fontSize: 12, + fontWeight: '700', + letterSpacing: 0.5, + textTransform: 'uppercase', }, menuButton: { - padding: 6, + padding: 8, borderRadius: 20, backgroundColor: 'rgba(255, 255, 255, 0.15)', }, @@ -345,21 +403,21 @@ const styles = StyleSheet.create({ }, balance: { color: 'white', - fontSize: 30, // Increased from 26 + fontSize: 28, fontWeight: '800', - textShadowColor: 'rgba(0, 0, 0, 0.4)', // Slightly stronger + textShadowColor: 'rgba(0, 0, 0, 0.4)', textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 4, // Increased from 3 - letterSpacing: -0.3, // Better readability for numbers + textShadowRadius: 4, + letterSpacing: -0.5, }, balanceUSD: { - color: 'rgba(255, 255, 255, 0.95)', // Increased opacity from 0.9 - fontSize: 18, // Increased from 17 + color: 'rgba(255, 255, 255, 0.95)', + fontSize: 17, fontWeight: '600', - marginTop: platformStyles.spacing.sm, // Increased from xs - textShadowColor: 'rgba(0, 0, 0, 0.3)', // Slightly stronger + marginTop: platformStyles.spacing.xs, + textShadowColor: 'rgba(0, 0, 0, 0.3)', textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 3, // Increased from 2 + textShadowRadius: 3, letterSpacing: 0.2, }, footer: { @@ -369,16 +427,22 @@ const styles = StyleSheet.create({ activeIndicator: { flexDirection: 'row', alignItems: 'center', - backgroundColor: 'rgba(255, 255, 255, 0.25)', // Increased opacity from 0.2 - paddingHorizontal: platformStyles.spacing.md + 2, // Increased - paddingVertical: platformStyles.spacing.xs + 3, // Increased + backgroundColor: 'rgba(255, 255, 255, 0.25)', + paddingHorizontal: platformStyles.spacing.md + 2, + paddingVertical: platformStyles.spacing.xs + 3, borderRadius: platformStyles.borderRadius.round, - gap: 5, // Increased from 4 + gap: 6, + }, + activeDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: '#4ADE80', }, activeText: { color: 'white', - fontSize: 14, // Increased from 13 - fontWeight: '700', // Increased from 600 + fontSize: 13, + fontWeight: '700', letterSpacing: 0.3, }, modalOverlay: { @@ -386,21 +450,32 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(0, 0, 0, 0.5)', }, menuContainer: { - // backgroundColor will be set dynamically via theme.colors.surface - borderRadius: platformStyles.borderRadius.large, + borderRadius: platformStyles.borderRadius.xl, padding: platformStyles.spacing.sm, - minWidth: 150, + minWidth: 160, ...platformStyles.cardShadow, }, menuItem: { flexDirection: 'row', alignItems: 'center', paddingVertical: platformStyles.spacing.md, - paddingHorizontal: platformStyles.spacing.sm, + paddingHorizontal: platformStyles.spacing.md, + borderRadius: platformStyles.borderRadius.medium, + }, + menuIconContainer: { + width: 32, + height: 32, + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + marginRight: platformStyles.spacing.md, + }, + menuDivider: { + height: StyleSheet.hairlineWidth, + marginHorizontal: platformStyles.spacing.md, }, menuText: { - marginLeft: platformStyles.spacing.sm, ...platformStyles.typography.body, - fontWeight: '500', + fontWeight: '600', }, -}); \ No newline at end of file +}); diff --git a/hooks/use-tab-animation.ts b/hooks/use-tab-animation.ts index 1335c054..9ce7e047 100644 --- a/hooks/use-tab-animation.ts +++ b/hooks/use-tab-animation.ts @@ -4,31 +4,43 @@ import { Animated } from 'react-native'; export const useTabAnimation = (tabIndex: number) => { const opacityAnim = useRef(new Animated.Value(1)).current; + const translateY = useRef(new Animated.Value(0)).current; const isInitialMount = useRef(true); - // Use useFocusEffect to detect when this tab comes into focus useFocusEffect( () => { // Skip animation on initial mount for instant first load if (isInitialMount.current) { isInitialMount.current = false; opacityAnim.setValue(1); + translateY.setValue(0); return; } - // Lightweight fade animation only - no slide for better performance - opacityAnim.setValue(0.85); - - Animated.timing(opacityAnim, { - toValue: 1, - duration: 150, // Reduced from 300ms for snappier feel - useNativeDriver: true, - }).start(); + // Combined fade + subtle slide animation for premium feel + opacityAnim.setValue(0); + translateY.setValue(8); + + Animated.parallel([ + Animated.timing(opacityAnim, { + toValue: 1, + duration: 220, + useNativeDriver: true, + }), + Animated.spring(translateY, { + toValue: 0, + damping: 20, + stiffness: 300, + mass: 0.8, + useNativeDriver: true, + }), + ]).start(); } ); const animatedStyle = { opacity: opacityAnim, + transform: [{ translateY }], }; return { animatedStyle };