diff --git a/e2e/discoverSheetFlow.spec.js b/e2e/discoverSheetFlow.spec.js index 517162d91ec..188fd007316 100644 --- a/e2e/discoverSheetFlow.spec.js +++ b/e2e/discoverSheetFlow.spec.js @@ -45,14 +45,24 @@ describe('Discover Screen Flow', () => { await Helpers.enableSynchronization(); }); - it('Should navigate to the Profile screen after swiping right', async () => { - await Helpers.swipe('wallet-screen', 'right', 'slow'); + it('Should navigate to Discover screen after swiping left', async () => { + await Helpers.swipe('wallet-screen', 'left', 'slow'); + await Helpers.checkIfVisible('discover-header'); + }); + + it('Should navigate to the Profile screen after swiping left', async () => { + await Helpers.swipe('discover-home', 'left', 'slow'); await Helpers.checkIfVisible('profile-screen'); }); - it('Should navigate to Discover screen after swiping left twice', async () => { + it('Should navigate to the Points screen after swiping left', async () => { await Helpers.swipe('profile-screen', 'left', 'slow'); - await Helpers.swipe('wallet-screen', 'left', 'slow'); + await Helpers.checkIfVisible('points-screen'); + }); + + it('Should navigate back to Discover screen after swiping right twice', async () => { + await Helpers.swipe('points-screen', 'right', 'slow'); + await Helpers.swipe('profile-screen', 'right', 'slow'); await Helpers.checkIfVisible('discover-header'); }); diff --git a/e2e/walletAvatarOptions.disabled.js b/e2e/walletAvatarOptions.disabled.js index 88a17edd51b..b4eb380de3f 100644 --- a/e2e/walletAvatarOptions.disabled.js +++ b/e2e/walletAvatarOptions.disabled.js @@ -22,7 +22,8 @@ describe('Wallet avatar options', () => { await Helpers.waitAndTap('import-sheet-button'); await Helpers.waitAndTap('wallet-info-submit-button'); await Helpers.checkIfVisible('wallet-screen', 40000); - await Helpers.swipe('wallet-screen', 'right', 'slow'); + await Helpers.swipe('wallet-screen', 'left', 'slow'); + await Helpers.swipe('discover-home', 'left', 'slow'); await Helpers.checkIfVisible('profile-screen', 40000); }); @@ -55,7 +56,8 @@ describe('Wallet avatar options', () => { } // Remove this once https://github.com/rainbow-me/rainbow/pull/4115 is merged. await Helpers.relaunchApp(); - await Helpers.swipe('wallet-screen', 'right', 'slow'); + await Helpers.swipe('wallet-screen', 'left', 'slow'); + await Helpers.swipe('discover-home', 'left', 'slow'); await Helpers.checkIfVisible('profile-screen', 40000); // TODO: check that wallet has different address (otherwise it means that creating wallet failed!). diff --git a/src/components/floating-emojis/CopyFloatingEmojis.js b/src/components/floating-emojis/CopyFloatingEmojis.js index 2015d17c409..50266fcb29e 100644 --- a/src/components/floating-emojis/CopyFloatingEmojis.js +++ b/src/components/floating-emojis/CopyFloatingEmojis.js @@ -8,6 +8,7 @@ const CopyFloatingEmojis = ({ children, disabled, onPress, + scaleTo, textToCopy, ...props }) => { @@ -30,10 +31,13 @@ const CopyFloatingEmojis = ({ onPress?.(textToCopy); if (!disabled) { onNewEmoji(); - setClipboard(textToCopy); + if (textToCopy) { + setClipboard(textToCopy); + } } }} radiusAndroid={24} + scaleTo={scaleTo} wrapperProps={{ containerStyle: { padding: 10, diff --git a/src/components/icons/Icon.js b/src/components/icons/Icon.js index 6fe64eeb229..c22b506cf9f 100644 --- a/src/components/icons/Icon.js +++ b/src/components/icons/Icon.js @@ -76,6 +76,9 @@ import TabDiscoverInnerFill from './svg/TabDiscoverInnerFill'; import TabHome from './svg/TabHome'; import TabHomeInner from './svg/TabHomeInner'; import TabHomeInnerFill from './svg/TabHomeInnerFill'; +import TabPoints from './svg/TabPoints'; +import TabPointsInner from './svg/TabPointsInner'; +import TabPointsInnerFill from './svg/TabPointsInnerFill'; import TelegramIcon from './svg/TelegramIcon'; import ThreeDotsIcon from './svg/ThreeDotsIcon'; import TouchIdIcon from './svg/TouchIdIcon'; @@ -166,6 +169,9 @@ const IconTypes = { tabHome: TabHome, tabHomeInner: TabHomeInner, tabHomeInnerFill: TabHomeInnerFill, + tabPoints: TabPoints, + tabPointsInner: TabPointsInner, + tabPointsInnerFill: TabPointsInnerFill, telegram: TelegramIcon, threeDots: ThreeDotsIcon, touchid: TouchIdIcon, diff --git a/src/components/icons/TabBarIcon.tsx b/src/components/icons/TabBarIcon.tsx index 0ac52322009..2f04cbfe7cf 100644 --- a/src/components/icons/TabBarIcon.tsx +++ b/src/components/icons/TabBarIcon.tsx @@ -16,16 +16,25 @@ type TabBarIconProps = { icon: string; index: number; reanimatedPosition: SharedValue; + hideShadow?: boolean; + tintBackdrop?: string; + tintOpacity?: number; }; export function TabBarIcon({ accentColor, + hideShadow, icon, index, reanimatedPosition, + tintBackdrop, + tintOpacity, }: TabBarIconProps) { const { colors, isDarkMode } = useTheme(); + const hasTransparentInnerFill = + icon === 'tabDiscover' || icon === 'tabPoints'; + const outlineColor = isDarkMode ? globalColors.blueGrey60 : globalColors.blueGrey70; @@ -78,21 +87,24 @@ export function TabBarIcon({ [index - 0.7, index - 0.3, index, index + 0.3, index + 0.7], [ isDarkMode ? outlineColor : '#FEFEFE', - icon === 'tabDiscover' - ? isDarkMode - ? '#171819' - : '#FEFEFE' - : accentColor, - icon === 'tabDiscover' - ? isDarkMode - ? '#171819' - : '#FEFEFE' - : accentColor, - icon === 'tabDiscover' - ? isDarkMode - ? '#171819' - : '#FEFEFE' - : accentColor, + tintBackdrop || + (hasTransparentInnerFill + ? isDarkMode + ? '#171819' + : '#FEFEFE' + : accentColor), + tintBackdrop || + (hasTransparentInnerFill + ? isDarkMode + ? '#171819' + : '#FEFEFE' + : accentColor), + tintBackdrop || + (hasTransparentInnerFill + ? isDarkMode + ? '#171819' + : '#FEFEFE' + : accentColor), isDarkMode ? outlineColor : '#FEFEFE', ] ); @@ -117,7 +129,7 @@ export function TabBarIcon({ const opacity = interpolate( reanimatedPosition.value, [index - 0.7, index - 0.3, index, index + 0.3, index + 0.7], - [0, 0.25, 0.25, 0.25, 0] + [0, tintOpacity ?? 0.25, tintOpacity ?? 0.25, tintOpacity ?? 0.25, 0] ); return { @@ -157,8 +169,8 @@ export function TabBarIcon({ }); return ( - - + + }> @@ -168,7 +180,7 @@ export function TabBarIcon({ style={innerFillColor} width={{ custom: 28 }} > - {icon === 'tabDiscover' && ( + {hasTransparentInnerFill && ( - {icon !== 'tabDiscover' && ( + {!hasTransparentInnerFill && ( }> { + return ( + + + + ); +}; + +export default TabPoints; diff --git a/src/components/icons/svg/TabPointsInner.js b/src/components/icons/svg/TabPointsInner.js new file mode 100644 index 00000000000..4ed5da3b6a3 --- /dev/null +++ b/src/components/icons/svg/TabPointsInner.js @@ -0,0 +1,18 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const TabPointsInner = ({ colors = undefined, color = colors.white }) => { + return ( + + + + ); +}; + +export default TabPointsInner; diff --git a/src/components/icons/svg/TabPointsInnerFill.js b/src/components/icons/svg/TabPointsInnerFill.js new file mode 100644 index 00000000000..cae178c7a6c --- /dev/null +++ b/src/components/icons/svg/TabPointsInnerFill.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Path } from 'react-native-svg'; +import Svg from '../Svg'; + +const TabPointsInnerFill = ({ colors = undefined, color = colors.black }) => { + return ( + + + + ); +}; + +export default TabPointsInnerFill; diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 607eaf7aca5..7ee4c0ac412 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -22,6 +22,7 @@ "tab_investments": "Pools", "tab_savings": "Savings", "tab_showcase": "Showcase", + "tab_points": "Points", "tab_positions": "Positions", "tab_transactions": "Transactions", "tab_transactions_tooltip": "Transactions and Token Transfers", @@ -1228,6 +1229,10 @@ "address_copied_to_clipboard": "Address copied to clipboard" } }, + "points": { + "coming_soon_title": "Coming Soon", + "coming_soon_description": "Points are just around the corner.\nStart earning today." + }, "pools": { "deposit": "Deposit", "pools_title": "Pools", diff --git a/src/model/config.ts b/src/model/config.ts index 0f9c8547fdb..b5b7446b158 100644 --- a/src/model/config.ts +++ b/src/model/config.ts @@ -68,6 +68,7 @@ export interface RainbowConfig base_swaps_enabled: boolean; mints_enabled: boolean; + points_enabled: boolean; } const DEFAULT_CONFIG: RainbowConfig = { @@ -127,6 +128,7 @@ const DEFAULT_CONFIG: RainbowConfig = { base_swaps_enabled: false, mints_enabled: true, + points_enabled: true, }; // Initialize with defaults in case firebase doesn't respond @@ -180,7 +182,8 @@ const init = async () => { key === 'op_chains_enabled' || key === 'goerli_enabled' || key === 'base_swaps_enabled' || - key === 'mints_enabled' + key === 'mints_enabled' || + key === 'points_enabled' ) { config[key] = entry.asBoolean(); } else { diff --git a/src/navigation/RecyclerListViewScrollToTopContext.tsx b/src/navigation/RecyclerListViewScrollToTopContext.tsx index d7200099d36..aeb45bc46a5 100644 --- a/src/navigation/RecyclerListViewScrollToTopContext.tsx +++ b/src/navigation/RecyclerListViewScrollToTopContext.tsx @@ -1,5 +1,6 @@ import { RecyclerListViewRef } from '@/components/asset-list/RecyclerAssetList2/core/ViewTypes'; import React, { createContext, useState } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; export const RecyclerListViewScrollToTopContext = createContext<{ scrollToTop: () => void; @@ -16,13 +17,15 @@ type ScrollToTopProviderProps = { const RecyclerListViewScrollToTopProvider: React.FC = ({ children, }) => { + const insets = useSafeAreaInsets(); + const [ scrollToTopRef, setScrollToTopRef, ] = useState(null); const scrollToTop = () => { - scrollToTopRef?.scrollToTop(true); + scrollToTopRef?.scrollToOffset(0, -insets.top, true); }; return ( diff --git a/src/navigation/SwipeNavigator.js b/src/navigation/SwipeNavigator.js index 65278d64437..0debdf4af8b 100644 --- a/src/navigation/SwipeNavigator.js +++ b/src/navigation/SwipeNavigator.js @@ -1,6 +1,6 @@ import { BlurView } from '@react-native-community/blur'; import { createMaterialTopTabNavigator } from '@react-navigation/material-top-tabs'; -import React, { useState } from 'react'; +import React, { useLayoutEffect, useState } from 'react'; import Animated, { useAnimatedStyle, useSharedValue, @@ -34,6 +34,7 @@ import { Box, Columns, Stack } from '@/design-system'; import { ButtonPressAnimation } from '@/components/animations'; import logger from 'logger'; +import PointsScreen from '@/screens/PointsScreen'; import WalletScreen from '@/screens/WalletScreen'; import RecyclerListViewScrollToTopProvider, { useRecyclerListViewScrollToTopContext, @@ -41,13 +42,14 @@ import RecyclerListViewScrollToTopProvider, { import { discoverOpenSearchFnRef } from '@/screens/discover/components/DiscoverSearchContainer'; import { InteractionManager, View } from 'react-native'; import { IS_DEV, IS_IOS, IS_TEST } from '@/env'; +import config from '@/model/config'; import SectionListScrollToTopProvider, { useSectionListScrollToTopContext, } from './SectionListScrollToTopContext'; -const NUMBER_OF_TABS = 3; +const HORIZONTAL_TAB_BAR_INSET = 6; -const config = { +const animationConfig = { animation: 'spring', config: { stiffness: 200, @@ -58,7 +60,6 @@ const config = { restSpeedThreshold: 0.01, }, }; -const tabConfig = { duration: 250, easing: Easing.elastic(1) }; const Swipe = createMaterialTopTabNavigator(); const HEADER_HEIGHT = IS_IOS ? 82 : 62; @@ -68,31 +69,35 @@ const TabBar = ({ descriptors, navigation, position, - isTap, - setIsTap, + jumpTo, lastPress, setLastPress, }) => { const { width: deviceWidth } = useDimensions(); - const reanimatedPosition = useSharedValue(2); - const tabWidth = deviceWidth / NUMBER_OF_TABS; - const tabPillStartPosition = (tabWidth - 72) / 2; + const { colors, isDarkMode } = useTheme(); + + const showPointsTab = IS_DEV || IS_TEST || config.points_enabled; + const NUMBER_OF_TABS = showPointsTab ? 4 : 3; + + const tabWidth = + (deviceWidth - HORIZONTAL_TAB_BAR_INSET * 2) / NUMBER_OF_TABS; + const tabPillStartPosition = (tabWidth - 72) / 2 + HORIZONTAL_TAB_BAR_INSET; const { accentColor } = useAccountAccentColor(); const recyclerList = useRecyclerListViewScrollToTopContext(); const sectionList = useSectionListScrollToTopContext(); - // //////////////////////////////////////////////////// - // Colors - const { colors, isDarkMode } = useTheme(); + const reanimatedPosition = useSharedValue(0); + const tabStyle = useAnimatedStyle(() => { const pos1 = tabPillStartPosition; const pos2 = tabPillStartPosition + tabWidth; const pos3 = tabPillStartPosition + tabWidth * 2; + const pos4 = tabPillStartPosition + tabWidth * 3; const translateX = interpolate( reanimatedPosition.value, - [0, 1, 2], - [pos1, pos2, pos3], + [0, 1, 2, 3], + [pos1, pos2, pos3, pos4], Extrapolate.EXTEND ); return { @@ -101,22 +106,14 @@ const TabBar = ({ }; }); - const animateTabs = (index, shouldAnimate = false) => { - if (shouldAnimate) { - reanimatedPosition.value = withTiming(index, tabConfig); - return; + useLayoutEffect(() => { + if (reanimatedPosition.value !== state.index) { + reanimatedPosition.value = state.index; } - reanimatedPosition.value = index; - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state.index]); - useEffect(() => { - if (!isTap) { - animateTabs(state.index, true); - } else { - animateTabs(state.index); - } - setIsTap(false); - }, [state.index, isTap]); + const [canSwitch, setCanSwitch] = useState(true); // for when QRScannerScreen is re-added // const offScreenTabBar = useAnimatedStyle(() => { @@ -164,7 +161,6 @@ const TabBar = ({ width="full" > - - {state.routes.map((route, index) => { - const { options } = descriptors[route.key]; - // logger.log('routeKey = ' + route.key); + + + {state.routes.map((route, index) => { + const { options } = descriptors[route.key]; + const isFocused = state.index === index; - const isFocused = state.index === index; + const onPress = () => { + if (!canSwitch) return; - const onPress = () => { - const event = navigation.emit({ - type: 'tabPress', - target: route.key, - }); + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + }); - const time = new Date().getTime(); - const delta = time - lastPress; + const time = new Date().getTime(); + const delta = time - lastPress; - const DOUBLE_PRESS_DELAY = 400; + const DOUBLE_PRESS_DELAY = 400; - if (!isFocused && !event.defaultPrevented) { - setIsTap(true); - navigation.navigate(route.name); - // animateTabs(index); - } else if ( - isFocused && - options.tabBarIcon === 'tabDiscover' - ) { - if (delta < DOUBLE_PRESS_DELAY) { - discoverOpenSearchFnRef?.(); - return; - } + if (!isFocused && !event.defaultPrevented) { + setCanSwitch(false); + jumpTo(route.key); + reanimatedPosition.value = index; + setTimeout(() => { + setCanSwitch(true); + }, 10); + } else if ( + isFocused && + options.tabBarIcon === 'tabDiscover' + ) { + if (delta < DOUBLE_PRESS_DELAY) { + discoverOpenSearchFnRef?.(); + return; + } - if (discoverScrollToTopFnRef?.() === 0) { - discoverOpenSearchFnRef?.(); - return; + if (discoverScrollToTopFnRef?.() === 0) { + discoverOpenSearchFnRef?.(); + return; + } + } else if (isFocused && options.tabBarIcon === 'tabHome') { + recyclerList.scrollToTop?.(); + } else if ( + isFocused && + options.tabBarIcon === 'tabActivity' + ) { + sectionList.scrollToTop?.(); } - } else if (isFocused && options.tabBarIcon === 'tabHome') { - recyclerList.scrollToTop?.(); - } else if ( - isFocused && - options.tabBarIcon === 'tabActivity' - ) { - sectionList.scrollToTop?.(); - } - setLastPress(time); - }; + setLastPress(time); + }; - const onLongPress = async () => { - navigation.emit({ - type: 'tabLongPress', - target: route.key, - }); - - if (options.tabBarIcon === 'tabHome') { - navigation.navigate(Routes.CHANGE_WALLET_SHEET); - } - if (options.tabBarIcon === 'tabDiscover') { - navigation.navigate(Routes.DISCOVER_SCREEN); - InteractionManager.runAfterInteractions(() => { - discoverOpenSearchFnRef?.(); + const onLongPress = async () => { + navigation.emit({ + type: 'tabLongPress', + target: route.key, }); - } - }; - return ( - options.tabBarIcon !== 'none' && ( - - { + discoverOpenSearchFnRef?.(); + }); + } + }; + + return ( + options.tabBarIcon !== 'none' && ( + - - + - - - - - - ) - ); - })} - + + + + + + + ) + ); + })} + + @@ -322,27 +326,30 @@ const TabBar = ({ export function SwipeNavigator() { const { isCoinListEdited } = useCoinListEdited(); const { network } = useAccountSettings(); - const [isTap, setIsTap] = useState(false); + const { colors } = useTheme(); const [lastPress, setLastPress] = useState(); + const showPointsTab = IS_DEV || IS_TEST || config.points_enabled; + // //////////////////////////////////////////////////// // Animations return ( - + ( @@ -356,19 +363,14 @@ export function SwipeNavigator() { tabBarIcon: 'none', }} /> */} - @@ -377,6 +379,18 @@ export function SwipeNavigator() { name={Routes.DISCOVER_SCREEN} options={{ tabBarIcon: 'tabDiscover' }} /> + + {showPointsTab && ( + + )} diff --git a/src/navigation/onNavigationStateChange.js b/src/navigation/onNavigationStateChange.js index 9c2227926a0..31338f85ed0 100644 --- a/src/navigation/onNavigationStateChange.js +++ b/src/navigation/onNavigationStateChange.js @@ -17,6 +17,7 @@ const isOnSwipeScreen = name => Routes.WALLET_SCREEN, Routes.DISCOVER_SCREEN, Routes.PROFILE_SCREEN, + Routes.POINTS_SCREEN, ].includes(name); export function triggerOnSwipeLayout(newAction) { @@ -60,6 +61,7 @@ export function onHandleStatusBar(currentState, prevState) { case Routes.PROFILE_SCREEN: case Routes.WALLET_SCREEN: case Routes.DISCOVER_SCREEN: + case Routes.POINTS_SCREEN: case Routes.WELCOME_SCREEN: case Routes.CHANGE_WALLET_SHEET: StatusBarHelper.setDarkContent(); diff --git a/src/navigation/routesNames.ts b/src/navigation/routesNames.ts index 2172e629e32..5c071198695 100644 --- a/src/navigation/routesNames.ts +++ b/src/navigation/routesNames.ts @@ -55,6 +55,7 @@ const Routes = { PAIR_HARDWARE_WALLET_SIGNING_SHEET: 'PairHardwareWalletSigningSheet', PAIR_HARDWARE_WALLET_SUCCESS_SHEET: 'PairHardwareWalletSuccessSheet', PIN_AUTHENTICATION_SCREEN: 'PinAuthenticationScreen', + POINTS_SCREEN: 'PointsScreen', POSITION_SHEET: 'PositionSheet', PROFILE_PREVIEW_SHEET: 'ProfilePreviewSheet', PROFILE_SCREEN: 'ProfileScreen', diff --git a/src/screens/PointsScreen.tsx b/src/screens/PointsScreen.tsx new file mode 100644 index 00000000000..7954c7120ae --- /dev/null +++ b/src/screens/PointsScreen.tsx @@ -0,0 +1,169 @@ +import lang from 'i18n-js'; +import React from 'react'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + Easing, + withDelay, + interpolate, + withTiming, + withSequence, +} from 'react-native-reanimated'; +import { CopyFloatingEmojis } from '@/components/floating-emojis'; +import { TabBarIcon } from '@/components/icons/TabBarIcon'; +import { Page } from '@/components/layout'; +import { Navbar } from '@/components/navbar/Navbar'; +import { Box, Stack, Text } from '@/design-system'; +import { useAccountAccentColor, useDimensions } from '@/hooks'; +import { useTheme } from '@/theme'; + +const fallConfig = { + duration: 2000, + easing: Easing.bezier(0.2, 0, 0, 1), +}; +const jumpConfig = { + duration: 500, + easing: Easing.bezier(0.2, 0, 0, 1), +}; +const flyUpConfig = { + duration: 2500, + easing: Easing.bezier(0.05, 0.7, 0.1, 1.0), +}; + +export default function PointsScreen() { + const { accentColor } = useAccountAccentColor(); + const { colors, isDarkMode } = useTheme(); + const { height: deviceHeight, width: deviceWidth } = useDimensions(); + + const iconState = useSharedValue(1); + const progress = useSharedValue(0); + + const animatedStyle = useAnimatedStyle(() => { + const scale = interpolate( + progress.value, + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [1.5, 1.4, 1.3, 1.1, 1.5, 1.4, 1.3, 1.1, 1.5] + ); + const rotate = interpolate( + progress.value, + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [-12, -4, -12, -12, -372, -380, -372, -372, -12] + ); + const translateX = interpolate( + progress.value, + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [0, 4, 0, 0, 0, -4, 0, 0, 0] + ); + const translateY = interpolate( + progress.value, + [0, 1, 2, 3, 4, 5, 6, 7, 8], + [-20, -5, 10, 14, -20, -5, 10, 14, -20] + ); + + return { + transform: [ + { translateX }, + { translateY }, + { rotate: `${rotate}deg` }, + { scale }, + ], + }; + }); + + React.useEffect(() => { + progress.value = 0; + progress.value = withDelay( + 500, + withRepeat( + withSequence( + withTiming(1, fallConfig), + withTiming(2, fallConfig), + withTiming(3, jumpConfig), + withTiming(4, flyUpConfig), + withTiming(5, fallConfig), + withTiming(6, fallConfig), + withTiming(7, jumpConfig), + withTiming(8, flyUpConfig) + ), + -1, + false + ) + ); + }, [progress]); + + return ( + + + + + + + + + + + {lang.t('points.coming_soon_title')} + + + {lang.t('points.coming_soon_description')} + + + + + + + + + + ); +}