diff --git a/app/(protected)/(tabs)/_layout.tsx b/app/(protected)/(tabs)/_layout.tsx index e27450db..db0e838e 100644 --- a/app/(protected)/(tabs)/_layout.tsx +++ b/app/(protected)/(tabs)/_layout.tsx @@ -20,7 +20,7 @@ export default function TabLayout() { return ( state.updateUser); const openSpinWinModal = useSpinWinModalStore(state => state.setModal); const intercom = useIntercom(); - const { data: cardStatus } = useCardStatus(); - const { data: cardDetails } = useCardDetails(); + const { data: cardStatus, isLoading: isCardStatusLoading } = useCardStatus(); + const { data: cardDetails, isLoading: isCardDetailsLoading } = useCardDetails(); const { data: spinStatus } = useSpinStatus(); const { data: giveaway } = useCurrentGiveaway(); const countdown = useGiveawayCountdown(giveaway?.giveawayDate); @@ -88,8 +88,9 @@ export default function Home() { hasTriggeredInitialRefresh.current = false; }, [user?.safeAddress]); - const { data: userDepositTransactions, isLoading: isDepositsLoading } = - useUserTransactions(user?.safeAddress); + const { data: userDepositTransactions, isLoading: isDepositsLoading } = useUserTransactions( + user?.safeAddress, + ); const { data: totalSavingsUSD, isLoading: isTotalSavingsLoading } = useTotalSavingsUSD(); @@ -117,6 +118,14 @@ export default function Home() { }, [user, intercom]); const isInitialLoading = isBalanceLoading || isLoadingTokens || isDepositsLoading; + const isCardBalanceLoading = isCardStatusLoading || (userHasCard && isCardDetailsLoading); + const isHeadlineLoading = + isLoadingTokens || + isBalanceLoading || + isTotalSavingsLoading || + isCardBalanceLoading || + totalSavingsUSD === undefined; + const headlineBalance = totalUSDExcludingVaultTokens + (totalSavingsUSD ?? 0) + cardBalance; if (!isInitialLoading && !balance && !isDeposited && !hasTokens) { return ; @@ -132,17 +141,15 @@ export default function Home() { - {isLoadingTokens || - isBalanceLoading || - isTotalSavingsLoading || - totalSavingsUSD === undefined ? ( + {isHeadlineLoading ? ( ) : ( - ) : isLoadingTokens || - isBalanceLoading || - isTotalSavingsLoading || - totalSavingsUSD === undefined ? ( + ) : isHeadlineLoading ? ( @@ -181,16 +185,14 @@ export default function Home() { ) : ( - + )} {isScreenMedium ? ( @@ -199,6 +201,7 @@ export default function Home() { totalUSDExcludingVaultTokens={totalUSDExcludingVaultTokens} topThreeTokens={topThreeTokens} isLoadingTokens={isLoadingTokens} + isLoadingCard={isCardBalanceLoading} userHasCard={userHasCard} cardBalance={cardBalance} /> diff --git a/app/(protected)/(tabs)/savings.tsx b/app/(protected)/(tabs)/savings.tsx index c65bdafc..cb77c8d0 100644 --- a/app/(protected)/(tabs)/savings.tsx +++ b/app/(protected)/(tabs)/savings.tsx @@ -149,7 +149,7 @@ export default function Savings() { const projectedEarnings = balance && vaultAPY ? balance * (exchangeRate ?? 1) * (vaultAPY / 100) : 0; - const stickyHeader = ( + const pageHeader = ( {isScreenMedium ? ( @@ -180,7 +180,8 @@ export default function Savings() { ); return ( - + + {pageHeader} {isScreenMedium ? ( diff --git a/app/_layout.tsx b/app/_layout.tsx index dda07540..c4be878d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -320,14 +320,14 @@ export default Sentry.wrap(function RootLayout() { } return ( - + - + {Platform.OS === 'web' && ( diff --git a/app/welcome.tsx b/app/welcome.tsx index fbe09d49..4b2dd3b4 100644 --- a/app/welcome.tsx +++ b/app/welcome.tsx @@ -132,7 +132,7 @@ export default function Welcome() { {/* Subtitle */} - + Please select your account to continue, you will be asked to login with your passkey. diff --git a/components/Card/BorrowPositionCard.tsx b/components/Card/BorrowPositionCard.tsx index 8693a48b..7a985f9f 100644 --- a/components/Card/BorrowPositionCard.tsx +++ b/components/Card/BorrowPositionCard.tsx @@ -140,7 +140,7 @@ export function BorrowPositionCard({ )} Net APY earned - + Net APY earned - + { const [isMounted, setIsMounted] = useState(false); - // On Android, delay showing AnimatedRollingNumber so we don't paint it with height=0 - // (stacked digits → white blob). Show static text for one frame then switch. - const [useRolling, setUseRolling] = useState(Platform.OS !== 'android'); + const [canAnimateAfterMount, setCanAnimateAfterMount] = useState(animateOnMount); + const [hasSwitchedToRolling, setHasSwitchedToRolling] = useState(animateOnMount); + // On Android, delay arming AnimatedRollingNumber so we don't paint it with height=0 + // (stacked digits → white blob). + const [useRolling, setUseRolling] = useState( + animated && animateOnMount && Platform.OS !== 'android', + ); useEffect(() => { setIsMounted(true); }, []); useEffect(() => { - if (Platform.OS === 'android' && isMounted) { + if (!animated) { + setUseRolling(false); + return; + } + if (Platform.OS !== 'android') { + setUseRolling(true); + return; + } + if (isMounted) { const t = requestAnimationFrame(() => { requestAnimationFrame(() => setUseRolling(true)); }); return () => cancelAnimationFrame(t); } - }, [isMounted]); + }, [animated, isMounted]); const safeCount = isFinite(count) && count >= 0 ? count : 0; const wholeNumber = Math.floor(safeCount); @@ -71,6 +87,8 @@ const CountUp = ({ const formattedText = isNaN(Number(trailingZero)) ? '0' : trailingZero; const formattedWhole = wholeNumber.toLocaleString('en-US'); + const displayKey = `${formattedWhole}.${formattedText}`; + const initialDisplayKeyRef = useRef(displayKey); const wholeStyle = useMemo( () => (Platform.OS === 'android' ? textStyleForAndroid(styles?.wholeText) : styles?.wholeText), @@ -82,7 +100,24 @@ const CountUp = ({ [styles?.decimalText], ); - const showRolling = isMounted && useRolling; + useEffect(() => { + if (!animated || animateOnMount || canAnimateAfterMount) return; + const t = requestAnimationFrame(() => setCanAnimateAfterMount(true)); + return () => cancelAnimationFrame(t); + }, [animated, animateOnMount, canAnimateAfterMount]); + + const hasChangedSinceInitial = displayKey !== initialDisplayKeyRef.current; + const showRolling = + animated && + isMounted && + useRolling && + (animateOnMount || hasSwitchedToRolling || (canAnimateAfterMount && hasChangedSinceInitial)); + + useEffect(() => { + if (showRolling && !hasSwitchedToRolling) { + setHasSwitchedToRolling(true); + } + }, [showRolling, hasSwitchedToRolling]); return ( @@ -93,31 +128,31 @@ const CountUp = ({ prefix ) ) : null} - {showRolling ? ( + {!showRolling ? {formattedWhole} : null} + {animated ? ( - ) : ( - {formattedWhole} - )} + ) : null} {decimalPlaces > 0 ? ( <> . - {showRolling ? ( + {!showRolling ? {formattedText} : null} + {animated ? ( - ) : ( - {formattedText} - )} + ) : null} ) : null} {suffix ? ( @@ -127,4 +162,13 @@ const CountUp = ({ ); }; +const countUpStyles = StyleSheet.create({ + hiddenRollingNumber: { + left: 0, + opacity: 0, + position: 'absolute', + top: 0, + }, +}); + export default CountUp; diff --git a/components/CustomTabBar.tsx b/components/CustomTabBar.tsx index 1f926d41..43a1e5b7 100644 --- a/components/CustomTabBar.tsx +++ b/components/CustomTabBar.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; -import { Platform, Pressable, StyleSheet, View, type ViewStyle } from 'react-native'; +import { useEffect, useRef, useState } from 'react'; +import { Animated, Platform, Pressable, StyleSheet, View, type ViewStyle } from 'react-native'; import * as Haptics from 'expo-haptics'; import { BottomTabBarProps } from '@react-navigation/bottom-tabs'; +import { CommonActions } from '@react-navigation/native'; import TabBarBackground from '@/components/ui/TabBarBackground'; import { Text } from '@/components/ui/text'; @@ -14,8 +15,26 @@ type TabButtonProps = { onLongPress: () => void; }; +const ACTIVE_TAB_COLOR = 'white'; +const INACTIVE_TAB_COLOR = 'rgba(255, 255, 255, 0.5)'; + function TabButton({ label, icon, isFocused, onPress, onLongPress }: TabButtonProps) { const [pressed, setPressed] = useState(false); + const focusProgress = useRef(new Animated.Value(isFocused ? 1 : 0)).current; + const labelColor = isFocused ? ACTIVE_TAB_COLOR : INACTIVE_TAB_COLOR; + + useEffect(() => { + Animated.timing(focusProgress, { + toValue: isFocused ? 1 : 0, + duration: 140, + useNativeDriver: true, + }).start(); + }, [focusProgress, isFocused]); + + const nativeFocusOpacity = focusProgress.interpolate({ + inputRange: [0, 1], + outputRange: [0.5, 1], + }); const handlePressIn = () => { setPressed(true); @@ -56,22 +75,24 @@ function TabButton({ label, icon, isFocused, onPress, onLongPress }: TabButtonPr style={styles.tabButton} > - {icon} - + {label} @@ -106,7 +127,10 @@ export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarPro }); if (!isFocused && !event.defaultPrevented) { - navigation.navigate(route.name); + navigation.dispatch({ + ...CommonActions.navigate(route), + target: state.key, + }); } }; @@ -120,7 +144,7 @@ export function CustomTabBar({ state, descriptors, navigation }: BottomTabBarPro // Get the icon const icon = options.tabBarIcon?.({ focused: isFocused, - color: isFocused ? 'white' : 'rgba(255, 255, 255, 1)', + color: ACTIVE_TAB_COLOR, size: Platform.OS === 'web' ? 36 : 40, }); diff --git a/components/Dashboard/DashboardHeaderMobile.tsx b/components/Dashboard/DashboardHeaderMobile.tsx index c116fe95..81207882 100644 --- a/components/Dashboard/DashboardHeaderMobile.tsx +++ b/components/Dashboard/DashboardHeaderMobile.tsx @@ -32,6 +32,7 @@ const DashboardHeaderMobile = ({ lastTimestamp={lastTimestamp ?? 0} mode={mode} decimalPlaces={decimalPlaces} + animateOnMount={false} classNames={{ wrapper: 'text-foreground', }} diff --git a/components/SavingCountUp.tsx b/components/SavingCountUp.tsx index 80d22d70..cc62a9f8 100644 --- a/components/SavingCountUp.tsx +++ b/components/SavingCountUp.tsx @@ -30,6 +30,8 @@ interface SavingCountUpProps { styles?: Styles; prefix?: string | React.ReactNode; suffix?: string; + animated?: boolean; + animateOnMount?: boolean; userDepositTransactions?: GetUserTransactionsQuery; exchangeRate?: number; tokenAddress?: string; @@ -53,6 +55,8 @@ const SavingCountUp = memo( styles, prefix, suffix, + animated, + animateOnMount, userDepositTransactions, exchangeRate = 1, tokenAddress = ADDRESSES.fuse.vault, @@ -82,6 +86,8 @@ const SavingCountUp = memo( styles={styles} prefix={prefix} suffix={suffix} + animated={animated} + animateOnMount={animateOnMount} /> ); }, diff --git a/components/Wallet/Card.tsx b/components/Wallet/Card.tsx index f2a25cb1..ab6cba82 100644 --- a/components/Wallet/Card.tsx +++ b/components/Wallet/Card.tsx @@ -43,6 +43,7 @@ const Card = ({ balance, className, tokens, isLoading, decimalPlaces }: CardProp count={balance ?? 0} isTrailingZero={false} decimalPlaces={decimalPlaces} + animateOnMount={false} classNames={{ wrapper: 'text-foreground', decimalSeparator: 'text-2xl md:text-3xl font-semibold', diff --git a/components/Wallet/DesktopCards.tsx b/components/Wallet/DesktopCards.tsx index 685e2d0b..70e7ad8c 100644 --- a/components/Wallet/DesktopCards.tsx +++ b/components/Wallet/DesktopCards.tsx @@ -9,6 +9,7 @@ type DesktopCardsProps = { totalUSDExcludingVaultTokens: number; topThreeTokens: TokenBalance[]; isLoadingTokens: boolean; + isLoadingCard?: boolean; userHasCard: boolean; cardBalance: number; }; @@ -17,6 +18,7 @@ export default function DesktopCards({ totalUSDExcludingVaultTokens, topThreeTokens, isLoadingTokens, + isLoadingCard, userHasCard, cardBalance, }: DesktopCardsProps) { @@ -34,7 +36,7 @@ export default function DesktopCards({ balance={cardBalance} className="flex-1" tokens={[USDC_TOKEN_BALANCE]} - isLoading={isLoadingTokens} + isLoading={isLoadingCard} decimalPlaces={2} /> )} diff --git a/components/Wallet/MobileCards.tsx b/components/Wallet/MobileCards.tsx index 7dd646e4..999cbbd7 100644 --- a/components/Wallet/MobileCards.tsx +++ b/components/Wallet/MobileCards.tsx @@ -10,6 +10,7 @@ type MobileCardsProps = { totalUSDExcludingVaultTokens: number; topThreeTokens: TokenBalance[]; isLoadingTokens: boolean; + isLoadingCard?: boolean; userHasCard: boolean; cardBalance: number; }; @@ -18,6 +19,7 @@ export default function MobileCards({ totalUSDExcludingVaultTokens, topThreeTokens, isLoadingTokens, + isLoadingCard, userHasCard, cardBalance, }: MobileCardsProps) { @@ -47,13 +49,20 @@ export default function MobileCards({ balance={cardBalance} className="h-full w-full" tokens={[USDC_TOKEN_BALANCE]} - isLoading={isLoadingTokens} + isLoading={isLoadingCard} decimalPlaces={2} /> ) : null, , ].filter(Boolean), - [totalUSDExcludingVaultTokens, topThreeTokens, isLoadingTokens, userHasCard, cardBalance], + [ + totalUSDExcludingVaultTokens, + topThreeTokens, + isLoadingTokens, + isLoadingCard, + userHasCard, + cardBalance, + ], ); const totalCards = cards.length; diff --git a/components/Wallet/SavingCard.tsx b/components/Wallet/SavingCard.tsx index 6663df41..dd1d5167 100644 --- a/components/Wallet/SavingCard.tsx +++ b/components/Wallet/SavingCard.tsx @@ -140,6 +140,7 @@ const SavingCard = memo(({ className, decimalPlaces = 2 }: SavingCardProps) => { prefix="$" count={totalSavingsUSD} decimalPlaces={decimalPlaces} + animateOnMount={false} classNames={{ wrapper: 'text-foreground', decimalSeparator: 'text-2xl md:text-3xl font-semibold', diff --git a/components/Wallet/WalletCard.tsx b/components/Wallet/WalletCard.tsx index c3d74beb..f5c1c98e 100644 --- a/components/Wallet/WalletCard.tsx +++ b/components/Wallet/WalletCard.tsx @@ -40,6 +40,7 @@ const WalletCard = ({ balance, className, tokens, isLoading, decimalPlaces }: Wa count={balance ?? 0} isTrailingZero={false} decimalPlaces={decimalPlaces} + animateOnMount={false} classNames={{ wrapper: 'text-foreground', decimalSeparator: 'text-2xl md:text-3xl font-semibold', diff --git a/hooks/useCardDetails.ts b/hooks/useCardDetails.ts index 607a91f4..5bf5abc1 100644 --- a/hooks/useCardDetails.ts +++ b/hooks/useCardDetails.ts @@ -49,8 +49,10 @@ export const useCardDetails = () => { () => ({ ...detailsQuery, data: mergedData, - isLoading: detailsQuery.isLoading, + isLoading: + detailsQuery.isLoading || + (provider === CardProvider.RAIN && !!detailsQuery.data && balanceQuery.isLoading), }), - [detailsQuery, mergedData], + [detailsQuery, mergedData, provider, balanceQuery.isLoading], ); }; diff --git a/hooks/useSavingsYield.ts b/hooks/useSavingsYield.ts index 9fc99350..7644a7b3 100644 --- a/hooks/useSavingsYield.ts +++ b/hooks/useSavingsYield.ts @@ -58,7 +58,12 @@ export function useSavingsYield({ summary, vault, }: UseSavingsYieldParams): number { - const [liveYield, setLiveYield] = useState(0); + const [liveYield, setLiveYield] = useState(() => { + if (balance <= 0 || !isFinite(balance)) return 0; + if (mode === SavingMode.BALANCE_ONLY) return balance; + if (mode === SavingMode.TOTAL_USD) return balance * exchangeRate; + return 0; + }); const [animation, setAnimation] = useState(0); const [anchor, setAnchor] = useState<{ value: number; time: number } | null>(null); const { user } = useUser(); @@ -78,6 +83,11 @@ export function useSavingsYield({ setAnchor(null); return; } + if (mode === SavingMode.BALANCE_ONLY) { + setLiveYield(balance); + setAnchor(null); + return; + } if (mode === SavingMode.TOTAL_USD) { setLiveYield(balance * exchangeRate); return; diff --git a/hooks/useTotalSavingsUSD.ts b/hooks/useTotalSavingsUSD.ts index b14d6a42..a5bdc886 100644 --- a/hooks/useTotalSavingsUSD.ts +++ b/hooks/useTotalSavingsUSD.ts @@ -49,9 +49,11 @@ export const useTotalSavingsUSD = (): { data: number | undefined; isLoading: boo const { data: exchangeRateFuse, isLoading: isLoadingRateFuse } = useVaultExchangeRate( fuseVault?.name ?? usdcVault.name, ); + const hasFuseBalance = !!fuseVault && (balanceFuse ?? 0) > 0; const { data: fusePriceUsd, isLoading: isLoadingFusePrice } = useQuery({ queryKey: ['fusePriceUsd'], queryFn: fetchFusePrice, + enabled: hasFuseBalance, staleTime: 5_000, refetchInterval: 5_000, }); @@ -60,19 +62,19 @@ export const useTotalSavingsUSD = (): { data: number | undefined; isLoading: boo isLoadingBalanceUsdc || isLoadingBalanceFuse || isLoadingRateUsdc || - isLoadingRateFuse || - isLoadingFusePrice; + (hasFuseBalance && (isLoadingRateFuse || isLoadingFusePrice)); const data = useMemo(() => { if (isLoading) return undefined; const rateUsdc = exchangeRateUsdc ?? 1; const rateFuse = exchangeRateFuse ?? 1; - const fusePrice = Number(fusePriceUsd) || 0; + const fusePrice = hasFuseBalance ? Number(fusePriceUsd) || 0 : 0; const redeemableUsdc = (balanceUsdc ?? 0) * rateUsdc; - const redeemableFuse = fuseVault ? (balanceFuse ?? 0) * rateFuse * fusePrice : 0; + const redeemableFuse = hasFuseBalance ? (balanceFuse ?? 0) * rateFuse * fusePrice : 0; return redeemableUsdc + redeemableFuse; }, [ isLoading, + hasFuseBalance, balanceUsdc, balanceFuse, exchangeRateUsdc,