diff --git a/src/components/animation/QrTransition.ts b/src/components/animation/QrTransition.ts new file mode 100644 index 00000000..70186a77 --- /dev/null +++ b/src/components/animation/QrTransition.ts @@ -0,0 +1,85 @@ +import type { RootStackParamList } from '@model/nav' +import { type NavigationProp,useNavigation } from '@react-navigation/core' +import { useThemeContext } from '@src/context/Theme' +import { useRef } from 'react' +import { Animated, Easing } from 'react-native' + +type StackNavigation = NavigationProp + +export const useTransitionAnimation = () => { + const nav = useNavigation() + const { color } = useThemeContext() + const animatedColorValue = useRef(new Animated.Value(0)).current + const animatedPositionValue = useRef(new Animated.Value(0)).current + const animatedOpacityValue = useRef(new Animated.Value(0)).current + const animatedMarginValue = useRef(new Animated.Value(0)).current + const animationEnded = useRef(false) + const interpolatedColor = animatedColorValue.interpolate({ + inputRange: animationEnded.current ? [1, 0] : [0, 1], + outputRange: animationEnded.current ? ['#000', color.BACKGROUND] : [color.BACKGROUND, '#000'], + }) + const interpolatedPosition = animatedPositionValue.interpolate({ + inputRange: animationEnded.current ? [1, 0] : [0, 1], + outputRange: animationEnded.current ? [100, 0] : [0, 100], + }) + const interpolatedOpacity = animatedOpacityValue.interpolate({ + inputRange: animationEnded.current ? [1, 0] : [0, 1], + outputRange: animationEnded.current ? [0, 1] : [1, 0], + }) + const interpolatedMargin = animatedMarginValue.interpolate({ + inputRange: animationEnded.current ? [1, 0] : [0, 1], + outputRange: animationEnded.current ? [-1000, 0] : [0, -1000], + }) + const animatedBgStyles = { + backgroundColor: interpolatedColor, + } + const animatedPosStyles = { + transform: [{ translateY: interpolatedPosition }], + } + const animatedOpacityStyles = { + opacity: interpolatedOpacity, + } + const animatedMarginStyles = { + marginTop: interpolatedMargin, + } + const animateTransition = () => { + Animated.parallel([ + Animated.timing(animatedColorValue, { + toValue: animationEnded.current ? 0 : 1, + duration: 300, + easing: Easing.linear, + useNativeDriver: false, + }), + Animated.timing(animatedPositionValue, { + toValue: animationEnded.current ? 0 : 1, + duration: 300, + easing: Easing.linear, + useNativeDriver: false, + }), + Animated.timing(animatedOpacityValue, { + toValue: animationEnded.current ? 0 : 1, + duration: 150, + easing: Easing.linear, + useNativeDriver: false, + }), + Animated.timing(animatedMarginValue, { + toValue: animationEnded.current ? 0 : 1, + duration: 300, + easing: Easing.linear, + useNativeDriver: false, + }) + ]).start(() => { + if (animationEnded.current) { return animationEnded.current = false } + nav.navigate('qr scan', { mint: undefined }) + animationEnded.current = true + }) + } + return { + animatedBgStyles, + animatedPosStyles, + animatedOpacityStyles, + animatedMarginStyles, + animationEnded, + animateTransition, + } +} \ No newline at end of file diff --git a/src/components/nav/BottomNav.tsx b/src/components/nav/BottomNav.tsx index 503430dd..f159aabf 100644 --- a/src/components/nav/BottomNav.tsx +++ b/src/components/nav/BottomNav.tsx @@ -8,10 +8,20 @@ import { STORE_KEYS } from '@store/consts' import { highlight as hi } from '@styles' import { isStr } from '@util' import { useTranslation } from 'react-i18next' -import { SafeAreaView, TouchableOpacity } from 'react-native' +import { Animated, SafeAreaView, TouchableOpacity } from 'react-native' import { s, ScaledSheet, vs } from 'react-native-size-matters' -export default function BottomNav({ navigation, route }: TBottomNavProps) { +type TInterPolation = Animated.AnimatedInterpolation + +export default function BottomNav({ + navigation, + route, + animatedBgStyles, + animatedPosStyles +}: TBottomNavProps & { + animatedBgStyles: { backgroundColor: TInterPolation }, + animatedPosStyles: { transform: { translateY: TInterPolation }[] } +}) { const { t } = useTranslation([NS.topNav]) const { color, highlight } = useThemeContext() @@ -37,51 +47,59 @@ export default function BottomNav({ navigation, route }: TBottomNavProps) { route.name === 'Contacts settings' return ( - - void handleNav('dashboard')} - disabled={isWalletRelatedScreen} - > - - - - void handleNav('Address book')} - disabled={route.name === 'Address book'} - > - - - - void handleNav('Settings')} - disabled={isSettingsRelatedScreen} - > - - - + + + void handleNav('dashboard')} + disabled={isWalletRelatedScreen} + > + + + + void handleNav('Address book')} + disabled={route.name === 'Address book'} + > + + + + void handleNav('Settings')} + disabled={isSettingsRelatedScreen} + > + + + + ) } diff --git a/src/screens/Dashboard.tsx b/src/screens/Dashboard.tsx index 96cd933d..d20e9689 100644 --- a/src/screens/Dashboard.tsx +++ b/src/screens/Dashboard.tsx @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-misused-promises */ +import { useTransitionAnimation } from '@comps/animation/QrTransition' import Balance from '@comps/Balance' import { IconBtn } from '@comps/Button' import useLoading from '@comps/hooks/Loading' @@ -31,15 +32,24 @@ import { claimToken, getMintsForPayment } from '@wallet' import { getTokenInfo } from '@wallet/proofs' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { TouchableOpacity, View } from 'react-native' +import { Animated, TouchableOpacity, View } from 'react-native' import { s, ScaledSheet, vs } from 'react-native-size-matters' export default function Dashboard({ navigation, route }: TDashboardPageProps) { const { t } = useTranslation([NS.common]) + // qr screen transition + const { + animatedBgStyles, + animatedPosStyles, + animatedOpacityStyles, + animatedMarginStyles, + animationEnded, + animateTransition, + } = useTransitionAnimation() // The URL content that redirects to this app after clicking on it (cashu:) const { url, clearUrl } = useInitialURL() // Theme - const { color, highlight } = useThemeContext() + const { highlight } = useThemeContext() // State to indicate token claim from clipboard after app comes to the foreground, to re-render total balance const { claimed } = useFocusClaimContext() // Nostr @@ -295,6 +305,12 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { // get balance after navigating to this page useEffect(() => { const focusHandler = navigation.addListener('focus', async () => { + if (animationEnded.current) { + const t = setTimeout(() => { + animateTransition() + clearTimeout(t) + }, 200) + } const data = await Promise.all([ getBalance(), hasMints() @@ -303,6 +319,7 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { setHasMint(data[1]) }) return focusHandler + // eslint-disable-next-line react-hooks/exhaustive-deps }, [navigation]) // prevent back navigation - https://reactnavigation.org/docs/preventing-going-back/ @@ -313,48 +330,50 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { }, [navigation]) return ( - - {/* Balance, Disclaimer & History */} - - {/* Receive/send/mints buttons */} - - {/* Send button or add first mint */} - {hasMint ? + + + {/* Balance, Disclaimer & History */} + + {/* Receive/send/mints buttons */} + + {/* Send button or add first mint */} + {hasMint ? + } + txt={t('send', { ns: NS.wallet })} + color={hi[highlight]} + onPress={() => setModal(prev => ({ ...prev, sendOpts: true }))} + /> + : + } + txt='Mint' + color={hi[highlight]} + onPress={() => setModal(prev => ({ ...prev, mint: true }))} + /> + } } - txt={t('send', { ns: NS.wallet })} + icon={} + txt={t('scan')} color={hi[highlight]} - onPress={() => setModal(prev => ({ ...prev, sendOpts: true }))} + onPress={() => animateTransition()} /> - : } - txt='Mint' + icon={} + txt={t('receive', { ns: NS.wallet })} color={hi[highlight]} - onPress={() => setModal(prev => ({ ...prev, mint: true }))} + onPress={() => { + if (!hasMint) { + // try to claim from clipboard to avoid receive-options-modal to popup and having to press again + return handleClaimBtnPress() + } + setModal(prev => ({ ...prev, receiveOpts: true })) + }} /> - } - } - txt={t('scan')} - color={hi[highlight]} - onPress={() => navigation.navigate('qr scan', { mint: undefined })} - /> - } - txt={t('receive', { ns: NS.wallet })} - color={hi[highlight]} - onPress={() => { - if (!hasMint) { - // try to claim from clipboard to avoid receive-options-modal to popup and having to press again - return handleClaimBtnPress() - } - setModal(prev => ({ ...prev, receiveOpts: true })) - }} - /> - + + {/* beta warning */} - + navigation.navigate('disclaimer')} style={styles.betaHint} @@ -363,9 +382,14 @@ export default function Dashboard({ navigation, route }: TDashboardPageProps) { - + {/* Bottom nav icons */} - + {/* Question modal for mint trusting */} {trustModal && - + ) }