diff --git a/packages/core-mobile/app/new/common/components/BlurViewWithFallback.tsx b/packages/core-mobile/app/new/common/components/BlurViewWithFallback.tsx index 5699cfa6f5..353c2d0582 100644 --- a/packages/core-mobile/app/new/common/components/BlurViewWithFallback.tsx +++ b/packages/core-mobile/app/new/common/components/BlurViewWithFallback.tsx @@ -9,6 +9,7 @@ export const BlurViewWithFallback = ({ children, intensity = 75, shouldDelayBlurOniOS = false, + backgroundColor, style }: { children?: React.ReactNode @@ -23,6 +24,7 @@ export const BlurViewWithFallback = ({ * reference: https://docs.expo.dev/versions/latest/sdk/blur-view/#known-issues */ shouldDelayBlurOniOS?: boolean + backgroundColor?: string style?: ViewStyle }): JSX.Element | null => { const [ready, setReady] = useState( @@ -43,19 +45,25 @@ export const BlurViewWithFallback = ({ const iosContainerStyle = useMemo( () => [ { - // alpha('#afafd0', 0.1) is a color value found through experimentation - // to make the blur effect appear the same as $surfacePrimary(neutral-850) in dark mode. - backgroundColor: - colorScheme === 'dark' ? alpha('#afafd0', 0.1) : undefined + backgroundColor: backgroundColor + ? alpha(backgroundColor, 0.1) + : // alpha('#afafd0', 0.1) is a color value found through experimentation + // to make the blur effect appear the same as $surfacePrimary(neutral-850) in dark mode. + colorScheme === 'dark' + ? alpha('#afafd0', 0.1) + : undefined }, style ], - [colorScheme, style] + [backgroundColor, colorScheme, style] ) const androidContainerStyle = useMemo( - () => [{ backgroundColor: theme.colors.$surfacePrimary }, style], - [style, theme.colors.$surfacePrimary] + () => [ + { backgroundColor: backgroundColor ?? theme.colors.$surfacePrimary }, + style + ], + [backgroundColor, style, theme.colors.$surfacePrimary] ) if (!ready || Platform.OS === 'android') { diff --git a/packages/core-mobile/app/new/common/components/BlurredBackgroundView.tsx b/packages/core-mobile/app/new/common/components/BlurredBackgroundView.tsx index 608272859f..e472b3bb50 100644 --- a/packages/core-mobile/app/new/common/components/BlurredBackgroundView.tsx +++ b/packages/core-mobile/app/new/common/components/BlurredBackgroundView.tsx @@ -12,7 +12,8 @@ import Grabber from './Grabber' const BlurredBackgroundView = ({ hasGrabber = false, separator, - shouldDelayBlurOniOS = false + shouldDelayBlurOniOS = false, + backgroundColor }: { hasGrabber?: boolean separator?: { @@ -20,6 +21,7 @@ const BlurredBackgroundView = ({ position: 'top' | 'bottom' } shouldDelayBlurOniOS?: boolean + backgroundColor?: string }): JSX.Element => { const animatedBorderStyle = useAnimatedStyle(() => ({ opacity: separator?.opacity.value @@ -42,6 +44,7 @@ const BlurredBackgroundView = ({ )} {hasGrabber === false && ( +export interface ListScreenProps extends Omit< FlatListProps, 'ListHeaderComponent' | 'ListFooterComponent' > { /** The title displayed in the screen header */ title: string + /** Optional subtitle displayed below the title */ + subtitle?: string /** Optional title to display in the navigation bar */ navigationTitle?: string /** Array of data items to be rendered in the list */ @@ -73,6 +69,8 @@ interface ListScreenProps isModal?: boolean /** Whether this screen has a tab bar */ hasTabBar?: boolean + /** Optional background color */ + backgroundColor?: string /** Whether to show the navigation header title */ showNavigationHeaderTitle?: boolean /** Optional function to render a custom sticky header component */ @@ -88,87 +86,157 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList) export const ListScreen = ({ data, title, + subtitle, navigationTitle, + showNavigationHeaderTitle = true, hasParent, - isModal, hasTabBar, - showNavigationHeaderTitle = true, + backgroundColor, + isModal, renderEmpty, renderHeader, renderHeaderRight, - ...rest + ...props }: ListScreenProps): JSX.Element => { const insets = useSafeAreaInsets() + const headerHeight = useHeaderHeight() + const keyboard = useKeyboardState() + const frame = useSafeAreaFrame() - const [headerLayout, setHeaderLayout] = useState< + const [targetLayout, setTargetLayout] = useState< LayoutRectangle | undefined >() - const headerRef = useRef(null) - const contentHeaderHeight = useSharedValue(0) - const keyboard = useKeyboardState() + const scrollViewRef = useRef(null) + + // Shared values for worklets (UI thread animations) + const titleHeight = useSharedValue(0) + const subtitleHeight = useSharedValue(0) + + // State for React re-renders (used in useMemo) + const [contentHeaderHeight, setContentHeaderHeight] = useState(0) + const [renderHeaderHeight, setRenderHeaderHeight] = useState(0) const { onScroll, scrollY, targetHiddenProgress } = useFadingHeaderNavigation( { header: , - targetLayout: headerLayout, + targetLayout, shouldHeaderHaveGrabber: isModal, + hideHeaderBackground: true, hasSeparator: renderHeader ? false : true, + backgroundColor, hasParent, showNavigationHeaderTitle, renderHeaderRight } ) - const animatedHeaderStyle = useAnimatedStyle(() => { - const scale = interpolate( - scrollY.value, - [-contentHeaderHeight.value, 0, contentHeaderHeight.value], - [0.94, 1, 0.94] - ) - return { - opacity: 1 - targetHiddenProgress.value, - transform: [{ scale: data.length === 0 ? 1 : scale }] - } - }) - - useLayoutEffect(() => { - if (headerRef.current) { - // eslint-disable-next-line max-params - headerRef.current.measure((x, y, w, h) => { - contentHeaderHeight.value = h - setHeaderLayout({ x, y, width: w, height: h / 2 }) - }) - } - }, [contentHeaderHeight]) - const onScrollEvent = useCallback( (event: NativeSyntheticEvent) => { onScroll(event) }, [onScroll] ) - const headerHeight = useHeaderHeight() + + const onScrollEndDrag = useCallback( + (event: NativeSyntheticEvent): void => { + 'worklet' + if (event.nativeEvent.contentOffset.y < contentHeaderHeight) { + if (event.nativeEvent.contentOffset.y > titleHeight.value) { + scrollViewRef.current?.scrollToOffset({ + offset: + event.nativeEvent.contentOffset.y > contentHeaderHeight + ? event.nativeEvent.contentOffset.y + : contentHeaderHeight + }) + } else { + scrollViewRef.current?.scrollToOffset({ + offset: 0 + }) + } + } + }, + [contentHeaderHeight, titleHeight] + ) + + const handleTitleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { height, x, y, width } = event.nativeEvent.layout + titleHeight.value = height + setTargetLayout({ + x, + y, + width, + height + }) + }, + [titleHeight] + ) + + const handleSubtitleLayout = useCallback( + (event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + subtitleHeight.value = height + }, + [subtitleHeight] + ) + + const handleRenderHeaderLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + setRenderHeaderHeight(height) + }, []) + + const handleContentHeaderLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + setContentHeaderHeight(height) + }, []) const animatedHeaderContainerStyle = useAnimatedStyle(() => { const translateY = interpolate( scrollY.value, - [0, contentHeaderHeight.value], - [0, -contentHeaderHeight.value - 8], - 'clamp' + [0, contentHeaderHeight], + [0, -contentHeaderHeight - (isModal ? 16 : 20)], + Extrapolation.CLAMP ) return { transform: [ { - translateY: withSpring(translateY, { - ...ANIMATED.SPRING_CONFIG, - stiffness: 100 - }) + translateY } ] } }) + const animatedTitleStyle = useAnimatedStyle(() => { + const scale = interpolate( + scrollY.value, + [ + -titleHeight.value - subtitleHeight.value, + 0, + titleHeight.value + subtitleHeight.value + ], + [0.95, 1, 0.95] + ) + return { + opacity: 1 - targetHiddenProgress.value, + transform: [{ scale: data.length === 0 ? 1 : scale }] + } + }) + + const animatedSubtitleStyle = useAnimatedStyle(() => { + return { + opacity: 1 - targetHiddenProgress.value + } + }) + + const animatedHeaderBlurStyle = useAnimatedStyle(() => { + // if we have a background color, we need to animate the opacity of the blur view + // so that it blends with the background color after scrolling + return { + opacity: backgroundColor ? targetHiddenProgress.value : 1 + } + }) + const animatedBorderStyle = useAnimatedStyle(() => { const opacity = interpolate(scrollY.value, [0, headerHeight], [0, 1]) return { @@ -176,62 +244,133 @@ export const ListScreen = ({ } }) + const contentContainerStyle = useMemo(() => { + const paddingBottom = keyboard.isVisible + ? keyboard.height + 16 + : insets.bottom + 16 + + // Android formsheet in native-stack has a default top padding of insets.top + // so we need to add this to adjust the height of the list + const extraPadding = + Platform.OS === 'android' ? (isModal ? insets.top + 24 : 32) : 6 + + return [ + props?.contentContainerStyle, + data.length === 0 + ? { + justifyContent: 'center', + flex: 1 + } + : {}, + { + paddingBottom, + minHeight: + frame.height + contentHeaderHeight + extraPadding - renderHeaderHeight + } + ] as StyleProp[] + }, [ + contentHeaderHeight, + data.length, + frame.height, + insets.bottom, + insets.top, + isModal, + keyboard.height, + keyboard.isVisible, + renderHeaderHeight, + props?.contentContainerStyle + ]) + const ListHeaderComponent = useMemo(() => { return ( - - + - {title ? ( - - - {title} - - - ) : null} - - - {renderHeader?.()} - - + - + + {title ? ( + + {title} + + ) : null} + + {subtitle ? ( + + + {subtitle} + + + ) : null} + + {renderHeader && ( + + {renderHeader?.()} + + )} + + + + ) }, [ - animatedBorderStyle, animatedHeaderContainerStyle, - animatedHeaderStyle, - headerRef, - headerHeight, renderHeader, - title + headerHeight, + animatedHeaderBlurStyle, + backgroundColor, + handleContentHeaderLayout, + title, + handleTitleLayout, + animatedTitleStyle, + subtitle, + handleSubtitleLayout, + animatedSubtitleStyle, + handleRenderHeaderLayout, + animatedBorderStyle ]) const ListEmptyComponent = useMemo(() => { @@ -247,43 +386,6 @@ export const ListScreen = ({ ) }, [renderEmpty]) - const frame = useSafeAreaFrame() - - const contentContainerStyle = useMemo(() => { - const paddingBottom = keyboard.isVisible - ? keyboard.height + 16 - : insets.bottom + 16 - - return [ - rest?.contentContainerStyle, - data.length === 0 - ? { - justifyContent: 'center', - flex: 1 - } - : {}, - { - paddingBottom, - minHeight: - frame.height - - (headerLayout?.height ?? 0) + - // Android formsheet in native-stack has a default top padding of insets.top - // so we need to add this to adjust the height of the list - (isModal && Platform.OS === 'android' ? insets.top - 16 : 0) - } - ] as StyleProp[] - }, [ - keyboard.isVisible, - keyboard.height, - insets.bottom, - insets.top, - rest?.contentContainerStyle, - data.length, - frame.height, - headerLayout?.height, - isModal - ]) - return ( ({ {/* @ts-expect-error */} ({ maxToRenderPerBatch={15} windowSize={12} initialNumToRender={15} + removeClippedSubviews={Platform.OS === 'android'} contentContainerStyle={contentContainerStyle} updateCellsBatchingPeriod={50} - {...rest} + {...props} + style={[ + props.style, + { + backgroundColor: backgroundColor ?? 'transparent' + } + ]} ListHeaderComponent={ListHeaderComponent} ListEmptyComponent={ListEmptyComponent} /> diff --git a/packages/core-mobile/app/new/common/components/SimpleTextInput.tsx b/packages/core-mobile/app/new/common/components/SimpleTextInput.tsx index 1df992456b..b1658d877a 100644 --- a/packages/core-mobile/app/new/common/components/SimpleTextInput.tsx +++ b/packages/core-mobile/app/new/common/components/SimpleTextInput.tsx @@ -53,6 +53,7 @@ export const SimpleTextInput = ({ fontFamily: 'Inter-Regular', height: 44, fontSize: 16, + lineHeight: 20, color: colors.$textPrimary }} value={value} diff --git a/packages/core-mobile/app/new/common/components/WalletCard.tsx b/packages/core-mobile/app/new/common/components/WalletCard.tsx index 080f9e6e68..21404b9a82 100644 --- a/packages/core-mobile/app/new/common/components/WalletCard.tsx +++ b/packages/core-mobile/app/new/common/components/WalletCard.tsx @@ -1,5 +1,5 @@ import { - GroupList, + ANIMATED, Icons, Text, TouchableOpacity, @@ -7,27 +7,41 @@ import { View } from '@avalabs/k2-alpine' import { useManageWallet } from 'common/hooks/useManageWallet' -import { WalletDisplayData } from 'common/types' -import React, { useCallback } from 'react' -import { StyleProp, ViewStyle } from 'react-native' +import { AccountDisplayData, WalletDisplayData } from 'common/types' +import { AccountListItem } from 'features/wallets/components/AccountListItem' +import { WalletBalance } from 'features/wallets/components/WalletBalance' +import React, { useCallback, useState } from 'react' +import { + FlatList, + LayoutChangeEvent, + ListRenderItem, + StyleProp, + ViewStyle +} from 'react-native' +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated' +import { WalletType } from 'services/wallet/types' import { DropdownMenu } from './DropdownMenu' -const ITEM_HEIGHT = 50 +const HEADER_HEIGHT = 64 const WalletCard = ({ wallet, + isActive, isExpanded, searchText, - onToggleExpansion, showMoreButton = true, - style + style, + renderBottom, + onToggleExpansion }: { wallet: WalletDisplayData + isActive: boolean isExpanded: boolean searchText: string - onToggleExpansion: () => void showMoreButton?: boolean style?: StyleProp + renderBottom?: () => React.JSX.Element + onToggleExpansion: () => void }): React.JSX.Element => { const { theme: { colors } @@ -37,65 +51,190 @@ const WalletCard = ({ const renderExpansionIcon = useCallback(() => { return ( ) - }, [colors.$textPrimary, isExpanded]) + }, [colors.$textSecondary, isExpanded]) const renderWalletIcon = useCallback(() => { + if ( + wallet.type === WalletType.LEDGER || + wallet.type === WalletType.LEDGER_LIVE + ) { + return + } if (isExpanded) { return } return - }, [colors.$textPrimary, isExpanded]) + }, [colors.$textPrimary, isExpanded, wallet.type]) + + const renderAccountItem: ListRenderItem = useCallback( + ({ item }) => { + return ( + + ) + }, + [] + ) + + const renderEmpty = useCallback(() => { + if (!searchText) { + return ( + + + No accounts in this wallet. + + + ) + } + + return null + }, [colors.$surfaceSecondary, colors.$textSecondary, searchText]) + + const [contentHeight, setContentHeight] = useState(HEADER_HEIGHT) + const onContentLayout = useCallback((event: LayoutChangeEvent) => { + const { height } = event.nativeEvent.layout + setContentHeight(height) + }, []) + + const animatedContentStyle = useAnimatedStyle(() => { + return { + minHeight: withTiming( + isExpanded ? contentHeight + HEADER_HEIGHT * 2 : HEADER_HEIGHT, + ANIMATED.TIMING_CONFIG + ) + } + }) return ( - + + item.account.id} + ListEmptyComponent={renderEmpty} + scrollEnabled={false} + /> + {renderBottom?.()} + + - + {renderExpansionIcon()} {renderWalletIcon()} - - {wallet.name} - + + + + {wallet.name} + + {isActive && ( + + )} + + + {wallet.accounts.length > 1 + ? `${wallet.accounts.length} accounts` + : '1 account'} + + + - {showMoreButton && ( - + + + {showMoreButton && ( - - )} - - - {isExpanded && ( - - {wallet.accounts.length > 0 ? ( - - ) : ( - !searchText && ( - - - No accounts in this wallet. - - - ) )} - )} - + + + + ) } diff --git a/packages/core-mobile/app/new/common/hooks/useFadingHeaderNavigation.tsx b/packages/core-mobile/app/new/common/hooks/useFadingHeaderNavigation.tsx index 9c29b2b0c7..3ce727c6a6 100644 --- a/packages/core-mobile/app/new/common/hooks/useFadingHeaderNavigation.tsx +++ b/packages/core-mobile/app/new/common/hooks/useFadingHeaderNavigation.tsx @@ -29,6 +29,7 @@ import Grabber from 'common/components/Grabber' export const useFadingHeaderNavigation = ({ header, targetLayout, + backgroundColor, shouldHeaderHaveGrabber = false, hideHeaderBackground = false, hasSeparator = true, @@ -46,6 +47,7 @@ export const useFadingHeaderNavigation = ({ hasParent?: boolean renderHeaderRight?: () => React.ReactNode showNavigationHeaderTitle?: boolean + backgroundColor?: string }): { onScroll: ( event: NativeSyntheticEvent | NativeScrollEvent | number @@ -135,6 +137,7 @@ export const useFadingHeaderNavigation = ({ ) : ( void - accessory: React.JSX.Element - }> + accounts: Array +} + +export type AccountDisplayData = { + wallet: Wallet + account: Account + isActive: boolean + hideSeparator: boolean + onPress: () => void + onPressDetails: () => void } diff --git a/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx b/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx index 0528aa9ee0..54f1b3d750 100644 --- a/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx +++ b/packages/core-mobile/app/new/features/accountSettings/components/AcccountList.tsx @@ -31,7 +31,12 @@ export const AccountList = (): React.JSX.Element => { const accountCollection = useSelector(selectAccounts) const flatListRef = useRef(null) - const { recentAccountIds, updateRecentAccount } = useRecentAccounts() + const { recentAccountIds: _recentAccountIds, updateRecentAccount } = + useRecentAccounts() + + const recentAccountIds = useMemo(() => { + return _recentAccountIds.slice(0, 5) + }, [_recentAccountIds]) useEffect(() => { if (recentAccountIds.length === 0 && activeAccount) { diff --git a/packages/core-mobile/app/new/features/accountSettings/store.ts b/packages/core-mobile/app/new/features/accountSettings/store.ts index 0c6e35b669..51c457ce65 100644 --- a/packages/core-mobile/app/new/features/accountSettings/store.ts +++ b/packages/core-mobile/app/new/features/accountSettings/store.ts @@ -25,17 +25,14 @@ export const recentAccountsStore = create()( recentAccountIds: [], addRecentAccounts: (accountIds: string[]) => set(state => ({ - recentAccountIds: [...state.recentAccountIds, ...accountIds].slice( - 0, - MAX_RECENT_ACCOUNTS - ) + recentAccountIds: [...state.recentAccountIds, ...accountIds] })), updateRecentAccount: (accountId: string) => set(state => ({ recentAccountIds: [ accountId, ...state.recentAccountIds.filter(id => id !== accountId) - ].slice(0, MAX_RECENT_ACCOUNTS) + ] })), deleteRecentAccounts: () => set({ @@ -64,5 +61,3 @@ export const recentAccountsStore = create()( export const useRecentAccounts = (): RecentAccountsState => { return recentAccountsStore() } - -const MAX_RECENT_ACCOUNTS = 5 diff --git a/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx b/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx index e8d261110f..83e165b328 100644 --- a/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx +++ b/packages/core-mobile/app/new/features/portfolio/screens/PortfolioScreen.tsx @@ -2,6 +2,7 @@ import { BalanceHeader, NavigationTitleHeader, SegmentedControl, + showAlert, useTheme, View } from '@avalabs/k2-alpine' @@ -14,10 +15,6 @@ import { OnTabChange } from 'common/components/CollapsibleTabs' import { HiddenBalanceText } from 'common/components/HiddenBalanceText' -import { useAccountPerformanceSummary } from 'features/portfolio/hooks/useAccountPerformanceSummary' -import { useBalanceTotalInCurrencyForAccount } from 'features/portfolio/hooks/useBalanceTotalInCurrencyForAccount' -import { useBalanceTotalPriceChangeForAccount } from 'features/portfolio/hooks/useBalanceTotalPriceChangeForAccount' -import { useIsBalanceLoadedForAccount } from 'features/portfolio/hooks/useIsBalanceLoadedForAccount' import { useErc20ContractTokens } from 'common/hooks/useErc20ContractTokens' import { useFadingHeaderNavigation } from 'common/hooks/useFadingHeaderNavigation' import { useSearchableTokenList } from 'common/hooks/useSearchableTokenList' @@ -34,6 +31,10 @@ import { ActionButtonTitle } from 'features/portfolio/assets/consts' import { CollectibleFilterAndSortInitialState } from 'features/portfolio/collectibles/hooks/useCollectiblesFilterAndSort' import { CollectiblesScreen } from 'features/portfolio/collectibles/screens/CollectiblesScreen' import { DeFiScreen } from 'features/portfolio/defi/components/DeFiScreen' +import { useAccountPerformanceSummary } from 'features/portfolio/hooks/useAccountPerformanceSummary' +import { useBalanceTotalInCurrencyForAccount } from 'features/portfolio/hooks/useBalanceTotalInCurrencyForAccount' +import { useBalanceTotalPriceChangeForAccount } from 'features/portfolio/hooks/useBalanceTotalPriceChangeForAccount' +import { useIsBalanceLoadedForAccount } from 'features/portfolio/hooks/useIsBalanceLoadedForAccount' import { useSendSelectedToken } from 'features/send/store' import { useNavigateToSwap } from 'features/swap/hooks/useNavigateToSwap' import { useFormatCurrency } from 'new/common/hooks/useFormatCurrency' @@ -42,7 +43,8 @@ import { InteractionManager, LayoutChangeEvent, LayoutRectangle, - Platform + Platform, + Pressable } from 'react-native' import Animated, { useAnimatedStyle, @@ -52,6 +54,7 @@ import { useSafeAreaFrame } from 'react-native-safe-area-context' import { useSelector } from 'react-redux' import AnalyticsService from 'services/analytics/AnalyticsService' import { AnalyticsEventName } from 'services/analytics/types' +import { WalletType } from 'services/wallet/types' import { selectActiveAccount } from 'store/account' import { LocalTokenWithBalance } from 'store/balance/types' import { @@ -61,10 +64,11 @@ import { import { selectIsDeveloperMode } from 'store/settings/advanced' import { selectSelectedCurrency } from 'store/settings/currency' import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' +import { selectActiveWallet, selectWallets } from 'store/wallet/slice' import { useFocusedSelector } from 'utils/performance/useFocusedSelector' -import { useIsRefetchingBalancesForAccount } from '../hooks/useIsRefetchingBalancesForAccount' -import { useIsLoadingBalancesForAccount } from '../hooks/useIsLoadingBalancesForAccount' import { useIsAllBalancesInaccurateForAccount } from '../hooks/useIsAllBalancesInaccurateForAccount' +import { useIsLoadingBalancesForAccount } from '../hooks/useIsLoadingBalancesForAccount' +import { useIsRefetchingBalancesForAccount } from '../hooks/useIsRefetchingBalancesForAccount' const SEGMENT_ITEMS = [ { title: 'Assets' }, @@ -120,6 +124,9 @@ const PortfolioHomeScreen = (): JSX.Element => { const isLoading = isRefetchingBalance || !isBalanceLoaded const allBalancesInaccurate = useIsAllBalancesInaccurateForAccount(activeAccount) + const activeWallet = useSelector(selectActiveWallet) + const wallets = useSelector(selectWallets) + const walletsCount = Object.keys(wallets).length const selectedCurrency = useSelector(selectSelectedCurrency) const { formatCurrency } = useFormatCurrency() const formattedBalance = useMemo(() => { @@ -255,6 +262,22 @@ const PortfolioHomeScreen = (): JSX.Element => { return }, []) + const openWalletsModal = useCallback(() => { + navigate({ + // @ts-ignore TODO: make routes typesafe + pathname: '/(signedIn)/(modals)/wallets' + }) + }, [navigate]) + + const handleErrorPress = useCallback(() => { + showAlert({ + title: 'Unable to load balances', + description: + 'This total may be incomplete since Core was unable to load all of the balances across each network.', + buttons: [{ text: 'Dismiss' }] + }) + }, []) + const renderHeader = useCallback((): JSX.Element => { return ( { }, animatedHeaderStyle ]}> - + + 1 ? activeWallet?.name : undefined} + walletIcon={ + activeWallet?.type === WalletType.LEDGER || + activeWallet?.type === WalletType.LEDGER_LIVE + ? 'ledger' + : 'wallet' + } + accountName={activeAccount?.name} + formattedBalance={formattedBalance} + currency={selectedCurrency} + priceChange={ + totalPriceChange !== 0 + ? { + formattedPrice: valueChange24h, + status: indicatorStatus, + formattedPercent: percentChange24h + } + : undefined + } + errorMessage={ + allBalancesInaccurate + ? 'Unable to load all balances' + : undefined + } + onErrorPress={handleErrorPress} + isLoading={isLoading && balanceTotalInCurrency === 0} + isLoadingBalances={isLoadingBalances || isLoading} + isPrivacyModeEnabled={isPrivacyModeEnabled} + isDeveloperModeEnabled={isDeveloperMode} + renderMaskView={renderMaskView} + /> + @@ -316,6 +349,10 @@ const PortfolioHomeScreen = (): JSX.Element => { handleStickyHeaderLayout, handleBalanceHeaderLayout, animatedHeaderStyle, + openWalletsModal, + walletsCount, + activeWallet?.name, + activeWallet?.type, activeAccount?.name, formattedBalance, selectedCurrency, @@ -324,6 +361,7 @@ const PortfolioHomeScreen = (): JSX.Element => { indicatorStatus, percentChange24h, allBalancesInaccurate, + handleErrorPress, isLoading, balanceTotalInCurrency, isLoadingBalances, diff --git a/packages/core-mobile/app/new/features/wallets/components/AccountBalance.tsx b/packages/core-mobile/app/new/features/wallets/components/AccountBalance.tsx new file mode 100644 index 0000000000..8d57811033 --- /dev/null +++ b/packages/core-mobile/app/new/features/wallets/components/AccountBalance.tsx @@ -0,0 +1,120 @@ +import { + ActivityIndicator, + alpha, + AnimatedBalance, + Icons, + Pressable, + useTheme, + View +} from '@avalabs/k2-alpine' +import { HiddenBalanceText } from 'common/components/HiddenBalanceText' +import { useFormatCurrency } from 'common/hooks/useFormatCurrency' +import { UNKNOWN_AMOUNT } from 'consts/amount' +import { useBalanceInCurrencyForAccount } from 'features/portfolio/hooks/useBalanceInCurrencyForAccount' +import { useIsAccountBalanceAccurate } from 'features/portfolio/hooks/useIsAccountBalanceAccurate' +import React, { useCallback, useMemo } from 'react' +import ContentLoader, { Rect } from 'react-content-loader/native' +import { useSelector } from 'react-redux' +import { Account } from 'store/account' +import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' + +export const AccountBalance = ({ + isActive, + account, + variant = 'spinner' +}: { + isActive: boolean + account: Account + variant?: 'spinner' | 'skeleton' +}): React.JSX.Element => { + const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) + const { + theme: { colors, isDark } + } = useTheme() + const { balance: accountBalance, isLoadingBalance } = + useBalanceInCurrencyForAccount(account.id) + + const isBalanceAccurate = useIsAccountBalanceAccurate(account) + const { formatCurrency } = useFormatCurrency() + + const refetchBalance = useCallback(() => { + // TODO: implement refetch balance + // dispatch(refetchBalanceForAccount(account.id)) + }, []) + + const balance = useMemo(() => { + return accountBalance === 0 + ? formatCurrency({ amount: 0 }).replace(/[\d.,]+/g, UNKNOWN_AMOUNT) + : formatCurrency({ + amount: accountBalance, + notation: accountBalance < 100000 ? undefined : 'compact' + }) + }, [accountBalance, formatCurrency]) + + const renderMaskView = useCallback(() => { + return ( + + ) + }, [colors.$textPrimary, isActive]) + + if (isLoadingBalance) { + if (variant === 'skeleton') { + return ( + + + + ) + } + + return + } + + return ( + + {!accountBalance ? null : !isBalanceAccurate ? ( + + + + ) : null} + + + ) +} diff --git a/packages/core-mobile/app/new/features/wallets/components/AccountListItem.tsx b/packages/core-mobile/app/new/features/wallets/components/AccountListItem.tsx new file mode 100644 index 0000000000..bfc1829b04 --- /dev/null +++ b/packages/core-mobile/app/new/features/wallets/components/AccountListItem.tsx @@ -0,0 +1,118 @@ +import { + alpha, + Icons, + Separator, + Text, + TouchableOpacity, + useTheme, + View +} from '@avalabs/k2-alpine' +import React from 'react' +import Animated, { Easing, LinearTransition } from 'react-native-reanimated' +import { Account } from 'store/account' +import { Wallet } from 'store/wallet/types' +import { AccountBalance } from './AccountBalance' + +export const AccountListItem = ({ + testID, + account, + wallet, + isActive, + hideSeparator, + onPress, + onPressDetails +}: { + testID: string + account: Account + wallet: Wallet + isActive: boolean + hideSeparator: boolean + onPress: () => void + onPressDetails: () => void +}): JSX.Element => { + const { theme } = useTheme() + + return ( + + + + + {isActive && ( + + )} + + + + + {account.name} + + + + + + + + + + + + + {!hideSeparator && } + + ) +} diff --git a/packages/core-mobile/app/new/features/wallets/components/WalletBalance.tsx b/packages/core-mobile/app/new/features/wallets/components/WalletBalance.tsx new file mode 100644 index 0000000000..df586e8d51 --- /dev/null +++ b/packages/core-mobile/app/new/features/wallets/components/WalletBalance.tsx @@ -0,0 +1,87 @@ +import { + ActivityIndicator, + alpha, + AnimatedBalance, + SxProp, + useTheme +} from '@avalabs/k2-alpine' +import { HiddenBalanceText } from 'common/components/HiddenBalanceText' +import { useFormatCurrency } from 'common/hooks/useFormatCurrency' +import { UNKNOWN_AMOUNT } from 'consts/amount' +import React, { useCallback, useMemo } from 'react' +import ContentLoader, { Rect } from 'react-content-loader/native' +import { useSelector } from 'react-redux' +import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' + +export const WalletBalance = ({ + // wallet, + balanceSx, + variant = 'spinner' +}: { + // wallet: Wallet + balanceSx?: SxProp + variant?: 'spinner' | 'skeleton' +}): JSX.Element => { + const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) + const { + theme: { colors, isDark } + } = useTheme() + const { formatCurrency } = useFormatCurrency() + + // TODO: get wallet balance + const walletBalance = 74235 + const isLoadingBalance = false + + const balance = useMemo(() => { + return walletBalance > 0 + ? formatCurrency({ + amount: walletBalance, + notation: walletBalance < 100000 ? undefined : 'compact' + }) + : formatCurrency({ amount: 0 }).replace(/[\d.,]+/g, UNKNOWN_AMOUNT) + }, [formatCurrency, walletBalance]) + + const renderMaskView = useCallback(() => { + return ( + + ) + }, [colors.$textPrimary]) + + if (isLoadingBalance) { + if (variant === 'skeleton') { + return ( + + + + ) + } + return + } + + return ( + + ) +} diff --git a/packages/core-mobile/app/new/features/wallets/components/WalletList.tsx b/packages/core-mobile/app/new/features/wallets/components/WalletList.tsx new file mode 100644 index 0000000000..2a1b47f58e --- /dev/null +++ b/packages/core-mobile/app/new/features/wallets/components/WalletList.tsx @@ -0,0 +1,466 @@ +import { + ActivityIndicator, + Button, + Icons, + SearchBar, + Text, + useTheme, + View +} from '@avalabs/k2-alpine' +import { ErrorState } from 'common/components/ErrorState' +import { ListScreen, ListScreenProps } from 'common/components/ListScreen' +import NavigationBarButton from 'common/components/NavigationBarButton' +import WalletCard from 'common/components/WalletCard' +import { useManageWallet } from 'common/hooks/useManageWallet' +import { WalletDisplayData } from 'common/types' +import { useRouter } from 'expo-router' +import { useRecentAccounts } from 'features/accountSettings/store' +import { useIsAccountBalanceAccurate } from 'features/portfolio/hooks/useIsAccountBalanceAccurate' +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { StyleProp, ViewStyle } from 'react-native' +import { useDispatch, useSelector } from 'react-redux' +import { WalletType } from 'services/wallet/types' +import { + Account, + selectAccounts, + selectActiveAccount, + setActiveAccount +} from 'store/account' +import { selectWallets } from 'store/wallet/slice' +import { + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID, + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME +} from '../consts' +import { getIsActiveWallet } from '../utils' + +export const WalletList = ({ + hasSearch, + backgroundColor, + walletStyle, + ...props +}: Omit< + ListScreenProps, + | 'data' + | 'renderItem' + | 'keyExtractor' + | 'renderHeader' + | 'renderHeaderRight' + | 'renderEmpty' +> & { + hasSearch?: boolean + backgroundColor?: string + walletStyle?: StyleProp +}): JSX.Element => { + const { + theme: { colors } + } = useTheme() + const dispatch = useDispatch() + const { navigate, dismiss } = useRouter() + const { handleAddAccount: handleAddAccountToWallet, isAddingAccount } = + useManageWallet() + + const [searchText, setSearchText] = useState('') + const [expandedWallets, setExpandedWallets] = useState< + Record + >({}) + + const accountCollection = useSelector(selectAccounts) + const allWallets = useSelector(selectWallets) + const activeAccount = useSelector(selectActiveAccount) + + // TODO: check if any account on any wallet has balanceAccurate === false + // Also check if balance is loading for any account on any wallet isLoadingWalletBalance === false + const balanceAccurate = useIsAccountBalanceAccurate(activeAccount) + const isLoadingWalletBalance = false + // TODO: Implement refresh + const isRefreshing = false + + const errorMessage = + balanceAccurate && !isLoadingWalletBalance + ? undefined + : 'Unable to load all balances' + const { recentAccountIds } = useRecentAccounts() + + const allAccountsArray = useMemo(() => { + const allAccounts = Object.values(accountCollection).filter( + (account): account is Account => account !== undefined + ) + + return allAccounts.sort((a, b) => { + const indexA = recentAccountIds.indexOf(a.id) + const indexB = recentAccountIds.indexOf(b.id) + + if (indexA !== -1 && indexB !== -1) return indexA - indexB + + return 0 + }) + }, [accountCollection, recentAccountIds]) + + useMemo(() => { + const initialExpansionState: Record = {} + const walletIds = [ + ...Object.keys(allWallets), + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID + ] + // Expand only the active wallet by default + walletIds.forEach(id => { + initialExpansionState[id] = getIsActiveWallet(id, activeAccount) + }) + setExpandedWallets(initialExpansionState) + }, [allWallets, activeAccount]) + + const gotoAccountDetails = useCallback( + (accountId: string): void => { + navigate({ + // @ts-ignore TODO: make routes typesafe + pathname: '/accountSettings/account', + params: { accountId } + }) + }, + [navigate] + ) + + const accountSearchResults = useMemo(() => { + if (!searchText) { + return allAccountsArray + } + + const lowerSearchText = searchText.toLowerCase() // Calculate once + + return allAccountsArray.filter(account => { + const wallet = allWallets[account.walletId] + if (!wallet) return false + + const isPrivateKeyAccount = wallet.type === WalletType.PRIVATE_KEY + + // Check virtual wallet first (most specific) + if ( + isPrivateKeyAccount && + IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME.toLowerCase().includes( + lowerSearchText + ) + ) { + return true + } + + // Check wallet name + if ( + !isPrivateKeyAccount && + wallet.name.toLowerCase().includes(lowerSearchText) + ) { + return true + } + + // Check account fields with early returns + const fieldsToCheck = [ + account.name, + account.addressC, + account.addressBTC, + account.addressAVM, + account.addressPVM, + account.addressSVM, + account.addressCoreEth + ] + + return fieldsToCheck.some(field => + field?.toLowerCase().includes(lowerSearchText) + ) + }) + }, [allAccountsArray, allWallets, searchText]) + + const handleSetActiveAccount = useCallback( + (accountId: string) => { + if (accountId === activeAccount?.id) { + return + } + dispatch(setActiveAccount(accountId)) + + dismiss() + dismiss() + }, + [activeAccount?.id, dispatch, dismiss] + ) + + const importedWallets = useMemo(() => { + return Object.values(allWallets).filter( + wallet => wallet.type === WalletType.PRIVATE_KEY + ) + }, [allWallets]) + + const primaryWallets = useMemo(() => { + return Object.values(allWallets).filter( + wallet => wallet.type !== WalletType.PRIVATE_KEY + ) + }, [allWallets]) + + const primaryWalletsDisplayData = useMemo(() => { + return primaryWallets.map(wallet => { + const accountsForWallet = accountSearchResults.filter( + account => account.walletId === wallet.id + ) + if (accountsForWallet.length === 0) { + return null + } + + const accountDataForWallet = accountsForWallet.map((account, index) => { + const isActive = account.id === activeAccount?.id + const nextAccount = accountsForWallet[index + 1] + const hideSeparator = + isActive || + nextAccount?.id === activeAccount?.id || + index === accountsForWallet.length - 1 + + return { + wallet, + account, + isActive, + hideSeparator, + onPress: () => handleSetActiveAccount(account.id), + onPressDetails: () => gotoAccountDetails(account.id) + } + }) + + return { + ...wallet, + accounts: accountDataForWallet + } as WalletDisplayData + }) + }, [ + primaryWallets, + accountSearchResults, + activeAccount?.id, + handleSetActiveAccount, + gotoAccountDetails + ]) + + const importedWalletsDisplayData = useMemo(() => { + // Get all accounts from private key wallets + const allPrivateKeyAccounts = importedWallets + .flatMap(wallet => + accountSearchResults.filter(account => account.walletId === wallet.id) + ) + .sort( + (a, b) => + recentAccountIds.indexOf(a.id) - recentAccountIds.indexOf(b.id) + ) + + if (allPrivateKeyAccounts.length === 0) { + return null + } + + // Create virtual "Private Key Accounts" wallet if there are any imported wallets + // Only add the virtual wallet if there are matching accounts (respects search) + const privateKeyAccountData = allPrivateKeyAccounts.map( + (account, index) => { + const isActive = account.id === activeAccount?.id + const nextAccount = allPrivateKeyAccounts[index + 1] + const hideSeparator = + isActive || + nextAccount?.id === activeAccount?.id || + index === allPrivateKeyAccounts.length - 1 + + return { + wallet: importedWallets.find(w => w.id === account.walletId), + account, + isActive, + hideSeparator, + onPress: () => handleSetActiveAccount(account.id), + onPressDetails: () => gotoAccountDetails(account.id) + } + } + ) + + // Create virtual wallet for private key accounts + return { + id: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID, // Virtual ID + name: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME, + type: WalletType.PRIVATE_KEY, + accounts: privateKeyAccountData + } as WalletDisplayData + }, [ + importedWallets, + accountSearchResults, + recentAccountIds, + activeAccount?.id, + handleSetActiveAccount, + gotoAccountDetails + ]) + + const walletsDisplayData: (WalletDisplayData | null)[] = useMemo(() => { + return [...primaryWalletsDisplayData, importedWalletsDisplayData] + }, [primaryWalletsDisplayData, importedWalletsDisplayData]) + + const toggleWalletExpansion = useCallback((walletId: string) => { + setExpandedWallets(prev => ({ + ...prev, + [walletId]: !prev[walletId] + })) + }, []) + + const onRefresh = useCallback(() => { + // TODO: Implement refresh + // dispatch(fetchWallets()) + }, []) + + const handleAddAccount = useCallback((): void => { + // @ts-ignore TODO: make routes typesafe + navigate('/accountSettings/importWallet') + }, [navigate]) + + const renderHeaderRight = useCallback(() => { + return ( + + + + ) + }, [colors.$textPrimary, handleAddAccount]) + + const renderHeader = useCallback(() => { + return ( + + {hasSearch && ( + + )} + {errorMessage && ( + + + + {errorMessage} + + + )} + + ) + }, [colors.$textDanger, errorMessage, hasSearch, searchText]) + + useEffect(() => { + // When searching, expand all wallets + if (searchText.length > 0) { + setExpandedWallets(prev => { + const newState = { ...prev } + Object.keys(newState).forEach(key => { + newState[key] = true + }) + return newState + }) + } + }, [searchText]) + + const renderItem = useCallback( + ({ item }: { item: WalletDisplayData }) => { + if (!item) { + return null + } + const isExpanded = expandedWallets[item.id] ?? false + const isActive = getIsActiveWallet(item.id, activeAccount) + const isAddingAccountToActiveWallet = + item.accounts.some(i => i?.isActive) && isAddingAccount + + if (searchText && item.accounts.length === 0) { + return null + } + + return ( + + item.type !== WalletType.PRIVATE_KEY ? ( + + ) : ( + <> + ) + } + onToggleExpansion={() => toggleWalletExpansion(item.id)} + showMoreButton={item.id !== IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID} + style={[ + { + marginHorizontal: 16, + marginVertical: 6 + }, + walletStyle + ]} + /> + ) + }, + [ + activeAccount, + colors.$textPrimary, + expandedWallets, + isAddingAccount, + searchText, + handleAddAccountToWallet, + toggleWalletExpansion, + walletStyle + ] + ) + + useEffect(() => { + // When searching, expand all wallets + if (searchText.length > 0) { + setExpandedWallets(prev => { + const newState = { ...prev } + Object.keys(newState).forEach(key => { + newState[key] = true + }) + return newState + }) + } + }, [searchText]) + + const renderEmpty = useCallback(() => { + return ( + + ) + }, []) + + return ( + + ) +} diff --git a/packages/core-mobile/app/new/features/wallets/consts.ts b/packages/core-mobile/app/new/features/wallets/consts.ts new file mode 100644 index 0000000000..23130dcc26 --- /dev/null +++ b/packages/core-mobile/app/new/features/wallets/consts.ts @@ -0,0 +1,2 @@ +export const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID = 'imported-accounts-wallet-id' +export const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME = 'Imported' diff --git a/packages/core-mobile/app/new/features/wallets/screens/WalletsScreen.tsx b/packages/core-mobile/app/new/features/wallets/screens/WalletsScreen.tsx new file mode 100644 index 0000000000..5d429bae35 --- /dev/null +++ b/packages/core-mobile/app/new/features/wallets/screens/WalletsScreen.tsx @@ -0,0 +1,21 @@ +import { useTheme } from '@avalabs/k2-alpine' +import React from 'react' +import { WalletList } from '../components/WalletList' + +const WalletsScreen = (): JSX.Element => { + const { theme } = useTheme() + return ( + + ) +} + +export { WalletsScreen } diff --git a/packages/core-mobile/app/new/features/wallets/utils.ts b/packages/core-mobile/app/new/features/wallets/utils.ts new file mode 100644 index 0000000000..45464b622d --- /dev/null +++ b/packages/core-mobile/app/new/features/wallets/utils.ts @@ -0,0 +1,17 @@ +import { CoreAccountType } from '@avalabs/types' +import { Account } from 'store/account' +import { IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID } from './consts' + +export function getIsActiveWallet( + id: string, + activeAccount?: Account +): boolean { + if (!activeAccount) { + return false + } + return ( + id === activeAccount.walletId || + (id === IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID && + activeAccount.type === CoreAccountType.IMPORTED) + ) +} diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/account.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/account.tsx index 75feaec250..b140f6d54c 100644 --- a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/account.tsx +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/account.tsx @@ -1,11 +1,11 @@ -import { BalanceHeader, View } from '@avalabs/k2-alpine' +import { BalanceHeader, showAlert, View } from '@avalabs/k2-alpine' import { ScrollScreen } from 'common/components/ScrollScreen' import { useFormatCurrency } from 'common/hooks/useFormatCurrency' import { UNKNOWN_AMOUNT } from 'consts/amount' import { useLocalSearchParams, useRouter } from 'expo-router' import { AccountAddresses } from 'features/accountSettings/components/accountAddresses' import { AccountButtons } from 'features/accountSettings/components/AccountButtons' -import React, { useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' import { useSelector } from 'react-redux' import { selectAccountById } from 'store/account' import { selectIsDeveloperMode } from 'store/settings/advanced' @@ -54,6 +54,15 @@ const AccountScreen = (): JSX.Element => { [account?.type, wallet?.type] ) + const handleErrorPress = useCallback(() => { + showAlert({ + title: 'Unable to load balances', + description: + 'This total may be incomplete since Core was unable to load all of the balances across each network.', + buttons: [{ text: 'Dismiss' }] + }) + }, []) + const handleShowPrivateKey = (): void => { if (!account) { return @@ -82,6 +91,7 @@ const AccountScreen = (): JSX.Element => { errorMessage={ allBalancesInaccurate ? 'Unable to load all balances' : undefined } + onErrorPress={handleErrorPress} isLoading={isLoading} isPrivacyModeEnabled={isPrivacyModeEnabled} isDeveloperModeEnabled={isDeveloperMode} diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/importWallet.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/importWallet.tsx index 10f29e3dbf..f85f73f92f 100644 --- a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/importWallet.tsx +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/importWallet.tsx @@ -1,63 +1,19 @@ import { GroupList, Icons, Text, useTheme, View } from '@avalabs/k2-alpine' -import { LoadingState } from 'common/components/LoadingState' import { ScrollScreen } from 'common/components/ScrollScreen' -import { useActiveWallet } from 'common/hooks/useActiveWallet' import { useRouter } from 'expo-router' -import { showSnackbar } from 'new/common/utils/toast' -import React, { useCallback, useMemo, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import AnalyticsService from 'services/analytics/AnalyticsService' -import { WalletType } from 'services/wallet/types' -import { addAccount } from 'store/account' -import { selectAccounts } from 'store/account/slice' +import React, { useMemo } from 'react' +import { useSelector } from 'react-redux' import { selectIsLedgerSupportBlocked } from 'store/posthog' -import { AppThunkDispatch } from 'store/types' -import { selectIsMigratingActiveAccounts } from 'store/wallet/slice' -import Logger from 'utils/Logger' const ITEM_HEIGHT = 70 const ImportWalletScreen = (): JSX.Element => { - const { back, navigate } = useRouter() + const { navigate } = useRouter() const { theme: { colors } } = useTheme() - const [isAddingAccount, setIsAddingAccount] = useState(false) - const accounts = useSelector(selectAccounts) - const dispatch = useDispatch() - const activeWallet = useActiveWallet() - const isLedgerSupportBlocked = useSelector(selectIsLedgerSupportBlocked) - const isMigratingActiveAccounts = useSelector(selectIsMigratingActiveAccounts) - - const handleCreateNewAccount = useCallback(async (): Promise => { - if (isAddingAccount) return - - try { - AnalyticsService.capture('AccountSelectorAddAccount', { - accountNumber: Object.keys(accounts).length + 1 - }) - - setIsAddingAccount(true) - await dispatch(addAccount(activeWallet.id)).unwrap() - AnalyticsService.capture('CreatedANewAccountSuccessfully', { - walletType: activeWallet.type - }) - } catch (error) { - Logger.error('Unable to add account', error) - showSnackbar('Unable to add account') - } finally { - setIsAddingAccount(false) - back() - } - }, [ - isAddingAccount, - accounts, - dispatch, - activeWallet.id, - activeWallet.type, - back - ]) + const isLedgerSupportBlocked = useSelector(selectIsLedgerSupportBlocked) const data = useMemo(() => { const handleTypeRecoveryPhrase = (): void => { @@ -144,46 +100,8 @@ const ImportWalletScreen = (): JSX.Element => { }) } - if ( - activeWallet?.type !== WalletType.PRIVATE_KEY && - !isMigratingActiveAccounts - ) { - return [ - { - title: 'Create new account', - subtitle: ( - - Add new multi-chain account - - ), - leftIcon: ( - - ), - accessory: ( - - ), - onPress: handleCreateNewAccount - }, - ...baseData - ] - } - return baseData - }, [ - navigate, - activeWallet?.type, - colors, - handleCreateNewAccount, - isLedgerSupportBlocked, - isMigratingActiveAccounts - ]) + }, [navigate, colors, isLedgerSupportBlocked]) return ( { marginTop: 24 }}> - {isAddingAccount && } ) diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx index b97fa52d92..e162fe3a6f 100644 --- a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/accountSettings/manageAccounts.tsx @@ -1,491 +1,8 @@ -import { truncateAddress } from '@avalabs/core-utils-sdk' -import { - ActivityIndicator, - alpha, - AnimatedBalance, - Icons, - SCREEN_WIDTH, - SearchBar, - Text, - TouchableOpacity, - useTheme, - View -} from '@avalabs/k2-alpine' -import { CoreAccountType } from '@avalabs/types' -import { ErrorState } from 'common/components/ErrorState' -import { HiddenBalanceText } from 'common/components/HiddenBalanceText' -import { ListScreen } from 'common/components/ListScreen' -import NavigationBarButton from 'common/components/NavigationBarButton' -import WalletCard from 'common/components/WalletCard' -import { TRUNCATE_ADDRESS_LENGTH } from 'common/consts/text' -import { useFormatCurrency } from 'common/hooks/useFormatCurrency' -import { WalletDisplayData } from 'common/types' -import { UNKNOWN_AMOUNT } from 'consts/amount' -import { useRouter } from 'expo-router' -import { useBalanceInCurrencyForAccount } from 'features/portfolio/hooks/useBalanceInCurrencyForAccount' -import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { useDispatch, useSelector } from 'react-redux' -import { WalletType } from 'services/wallet/types' -import { - Account, - selectAccounts, - selectActiveAccount, - setActiveAccount -} from 'store/account' -import { selectIsPrivacyModeEnabled } from 'store/settings/securityPrivacy' -import { selectActiveWalletId, selectWallets } from 'store/wallet/slice' - -const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID = 'imported-accounts-wallet-id' -const IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME = 'Imported' +import { WalletList } from 'features/wallets/components/WalletList' +import React from 'react' const ManageAccountsScreen = (): React.JSX.Element => { - const { - theme: { colors } - } = useTheme() - const dispatch = useDispatch() - const { navigate, dismiss } = useRouter() - const [searchText, setSearchText] = useState('') - const accountCollection = useSelector(selectAccounts) - const allWallets = useSelector(selectWallets) - const activeWalletId = useSelector(selectActiveWalletId) - const activeAccount = useSelector(selectActiveAccount) - - const [expandedWallets, setExpandedWallets] = useState< - Record - >({}) - - useMemo(() => { - const initialExpansionState: Record = {} - const walletIds = [ - ...Object.keys(allWallets), - IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID - ] - if (walletIds.length > 0) { - // Expand only the active wallet by default - walletIds.forEach(id => { - initialExpansionState[id] = - id === activeWalletId || - (id === IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID && - activeAccount?.type === CoreAccountType.IMPORTED) - }) - } - setExpandedWallets(initialExpansionState) - }, [allWallets, activeWalletId, activeAccount?.type]) - - const allAccountsArray: Account[] = useMemo( - () => Object.values(accountCollection), - [accountCollection] - ) - - const gotoAccountDetails = useCallback( - (accountId: string): void => { - navigate({ - // @ts-ignore TODO: make routes typesafe - pathname: '/accountSettings/account', - params: { accountId } - }) - }, - [navigate] - ) - - const accountSearchResults = useMemo(() => { - if (!searchText) { - return allAccountsArray - } - return allAccountsArray.filter(account => { - const wallet = allWallets[account.walletId] - if (!wallet) { - return false - } - const walletName = wallet.name.toLowerCase() - - const isPrivateKeyAccount = wallet.type === WalletType.PRIVATE_KEY - const virtualWalletMatches = - isPrivateKeyAccount && - IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME.toLowerCase().includes( - searchText.toLowerCase() - ) - const walletNameMatches = - !isPrivateKeyAccount && walletName.includes(searchText.toLowerCase()) - - return ( - virtualWalletMatches || - walletNameMatches || - account.name.toLowerCase().includes(searchText.toLowerCase()) || - account.addressC?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressBTC?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressAVM?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressPVM?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressSVM?.toLowerCase().includes(searchText.toLowerCase()) || - account.addressCoreEth?.toLowerCase().includes(searchText.toLowerCase()) - ) - }) - }, [allAccountsArray, allWallets, searchText]) - - const handleSetActiveAccount = useCallback( - (accountId: string) => { - if (accountId === activeAccount?.id) { - return - } - dispatch(setActiveAccount(accountId)) - - dismiss() - dismiss() - }, - [activeAccount?.id, dispatch, dismiss] - ) - - const importedWallets = useMemo(() => { - return Object.values(allWallets).filter( - wallet => wallet.type === WalletType.PRIVATE_KEY - ) - }, [allWallets]) - - const primaryWallets = useMemo(() => { - return Object.values(allWallets).filter( - wallet => wallet.type !== WalletType.PRIVATE_KEY - ) - }, [allWallets]) - - const primaryWalletsDisplayData = useMemo(() => { - return primaryWallets.map(wallet => { - const accountsForWallet = accountSearchResults.filter( - account => account.walletId === wallet.id - ) - - if (accountsForWallet.length === 0) { - return null - } - - const accountDataForWallet = accountsForWallet.map((account, index) => { - const isActive = account.id === activeAccount?.id - const nextAccount = accountsForWallet[index + 1] - const hideSeparator = isActive || nextAccount?.id === activeAccount?.id - - return { - hideSeparator, - containerSx: { - backgroundColor: isActive - ? alpha(colors.$textPrimary, 0.1) - : 'transparent', - borderRadius: 8 - }, - title: ( - - {account.name} - - ), - subtitle: ( - - {truncateAddress(account.addressC, TRUNCATE_ADDRESS_LENGTH)} - - ), - leftIcon: isActive ? ( - - ) : ( - - ), - value: , - onPress: () => handleSetActiveAccount(account.id), - accessory: ( - gotoAccountDetails(account.id)}> - - - ) - } - }) - - return { - ...wallet, - accounts: accountDataForWallet - } - }) - }, [ - primaryWallets, - accountSearchResults, - activeAccount?.id, - colors.$textPrimary, - colors.$textSecondary, - handleSetActiveAccount, - gotoAccountDetails - ]) - - const importedWalletsDisplayData = useMemo(() => { - // Get all accounts from private key wallets - const allPrivateKeyAccounts = importedWallets.flatMap(wallet => { - return accountSearchResults.filter( - account => account.walletId === wallet.id - ) - }) - - if (allPrivateKeyAccounts.length === 0) { - return null - } - - // Create virtual "Private Key Accounts" wallet if there are any imported wallets - // Only add the virtual wallet if there are matching accounts (respects search) - const privateKeyAccountData = allPrivateKeyAccounts.map( - (account, index) => { - const isActive = account.id === activeAccount?.id - const nextAccount = allPrivateKeyAccounts[index + 1] - const hideSeparator = isActive || nextAccount?.id === activeAccount?.id - - return { - hideSeparator, - containerSx: { - backgroundColor: isActive - ? alpha(colors.$textPrimary, 0.1) - : 'transparent', - borderRadius: 8 - }, - title: ( - - {account.name} - - ), - subtitle: ( - - {truncateAddress(account.addressC, TRUNCATE_ADDRESS_LENGTH)} - - ), - leftIcon: isActive ? ( - - ) : ( - - ), - value: , - onPress: () => handleSetActiveAccount(account.id), - accessory: ( - gotoAccountDetails(account.id)}> - - - ) - } - } - ) - - // Create virtual wallet for private key accounts - return { - id: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID, // Virtual ID - name: IMPORTED_ACCOUNTS_VIRTUAL_WALLET_NAME, - type: WalletType.PRIVATE_KEY, - accounts: privateKeyAccountData - } - }, [ - importedWallets, - accountSearchResults, - activeAccount?.id, - colors.$textPrimary, - colors.$textSecondary, - handleSetActiveAccount, - gotoAccountDetails - ]) - - const walletsDisplayData: (WalletDisplayData | null)[] = useMemo(() => { - return [...primaryWalletsDisplayData, importedWalletsDisplayData] - }, [primaryWalletsDisplayData, importedWalletsDisplayData]) - - const toggleWalletExpansion = useCallback((walletId: string) => { - setExpandedWallets(prev => ({ - ...prev, - [walletId]: !prev[walletId] - })) - }, []) - - const handleAddAccount = useCallback((): void => { - // @ts-ignore TODO: make routes typesafe - navigate('/accountSettings/importWallet') - }, [navigate]) - - const renderHeaderRight = useCallback(() => { - return ( - - - - ) - }, [colors.$textPrimary, handleAddAccount]) - - const renderHeader = useCallback(() => { - return - }, [searchText]) - - useEffect(() => { - // When searching, expand all wallets - if (searchText.length > 0) { - setExpandedWallets(prev => { - const newState = { ...prev } - Object.keys(newState).forEach(key => { - newState[key] = true - }) - return newState - }) - } - }, [searchText]) - - const renderItem = useCallback( - ({ item }: { item: WalletDisplayData }) => { - if (!item) { - return null - } - const isExpanded = expandedWallets[item.id] ?? false - - if (searchText && item.accounts.length === 0) { - return null - } - - return ( - toggleWalletExpansion(item.id)} - showMoreButton={item.id !== IMPORTED_ACCOUNTS_VIRTUAL_WALLET_ID} - style={{ - marginHorizontal: 16, - marginTop: 12 - }} - /> - ) - }, - [expandedWallets, searchText, toggleWalletExpansion] - ) - - const renderEmpty = useCallback(() => { - return ( - - ) - }, []) - - return ( - item.id} - renderItem={renderItem} - /> - ) + return } export default ManageAccountsScreen - -const AccountBalance = ({ - isActive, - accountId -}: { - isActive: boolean - accountId: string -}): React.JSX.Element => { - const isPrivacyModeEnabled = useSelector(selectIsPrivacyModeEnabled) - const { - theme: { colors } - } = useTheme() - const { balance: accountBalance, isLoadingBalance } = - useBalanceInCurrencyForAccount(accountId) - const { formatCurrency } = useFormatCurrency() - - const balance = useMemo(() => { - return accountBalance === 0 - ? formatCurrency({ amount: 0 }).replace(/[\d.,]+/g, UNKNOWN_AMOUNT) - : formatCurrency({ amount: accountBalance }) - }, [accountBalance, formatCurrency]) - - const renderMaskView = useCallback(() => { - return ( - - ) - }, [colors.$textPrimary, isActive]) - - if (isLoadingBalance) { - return - } - - return ( - - ) -} diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/wallets/_layout.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/wallets/_layout.tsx new file mode 100644 index 0000000000..2d627c9e1e --- /dev/null +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/wallets/_layout.tsx @@ -0,0 +1,11 @@ +import { Stack } from 'common/components/Stack' +import { stackScreensOptions } from 'common/consts/screenOptions' +import React from 'react' + +export default function WalletsLayout(): JSX.Element { + return ( + + + + ) +} diff --git a/packages/core-mobile/app/new/routes/(signedIn)/(modals)/wallets/index.tsx b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/wallets/index.tsx new file mode 100644 index 0000000000..c1a0c76a47 --- /dev/null +++ b/packages/core-mobile/app/new/routes/(signedIn)/(modals)/wallets/index.tsx @@ -0,0 +1 @@ +export { WalletsScreen as default } from 'features/wallets/screens/WalletsScreen' diff --git a/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx b/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx index 82b5f04db6..0185a85593 100644 --- a/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx +++ b/packages/core-mobile/app/new/routes/(signedIn)/_layout.tsx @@ -230,6 +230,14 @@ export default function WalletLayout(): JSX.Element { options={modalScreensOptions} /> + diff --git a/packages/k2-alpine/src/assets/icons/expand_all.svg b/packages/k2-alpine/src/assets/icons/expand_all.svg new file mode 100644 index 0000000000..a32f2925ff --- /dev/null +++ b/packages/k2-alpine/src/assets/icons/expand_all.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/k2-alpine/src/assets/icons/wallet.svg b/packages/k2-alpine/src/assets/icons/wallet.svg index 8beef4d82a..67bb47e109 100644 --- a/packages/k2-alpine/src/assets/icons/wallet.svg +++ b/packages/k2-alpine/src/assets/icons/wallet.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/packages/k2-alpine/src/assets/icons/wallet_closed.svg b/packages/k2-alpine/src/assets/icons/wallet_closed.svg index 7d2c83cd17..9a49bb5328 100644 --- a/packages/k2-alpine/src/assets/icons/wallet_closed.svg +++ b/packages/k2-alpine/src/assets/icons/wallet_closed.svg @@ -1,5 +1,4 @@ - - - - + + + diff --git a/packages/k2-alpine/src/components/Header/BalanceHeader.tsx b/packages/k2-alpine/src/components/Header/BalanceHeader.tsx index d4df061c21..ef4f2cf10a 100644 --- a/packages/k2-alpine/src/components/Header/BalanceHeader.tsx +++ b/packages/k2-alpine/src/components/Header/BalanceHeader.tsx @@ -1,7 +1,8 @@ import React, { useCallback } from 'react' -import { LayoutChangeEvent } from 'react-native' +import { LayoutChangeEvent, Pressable } from 'react-native' +import { SCREEN_WIDTH } from '../../const' +import { useTheme } from '../../hooks' import { Icons } from '../../theme/tokens/Icons' -import { colors } from '../../theme/tokens/colors' import { AnimatedBalance } from '../AnimatedBalance/AnimatedBalance' import { LoadingContent } from '../LoadingContent/LoadingContent' import { PriceChangeIndicator } from '../PriceChangeIndicator/PriceChangeIndicator' @@ -12,6 +13,8 @@ import { PrivacyModeAlert } from './PrivacyModeAlert' export const BalanceHeader = ({ accountName, + walletName, + walletIcon, formattedBalance, currency, errorMessage, @@ -22,21 +25,28 @@ export const BalanceHeader = ({ isPrivacyModeEnabled = false, isDeveloperModeEnabled = false, renderMaskView, - testID + testID, + onErrorPress }: { accountName?: string + walletName?: string formattedBalance: string currency: string errorMessage?: string priceChange?: PriceChange + walletIcon?: 'wallet' | 'ledger' onLayout?: (event: LayoutChangeEvent) => void isLoading?: boolean isLoadingBalances?: boolean isPrivacyModeEnabled?: boolean isDeveloperModeEnabled?: boolean testID?: string + onErrorPress?: () => void renderMaskView?: () => React.JSX.Element }): React.JSX.Element => { + const { + theme: { colors } + } = useTheme() const renderPriceChangeIndicator = useCallback((): React.JSX.Element => { if (isDeveloperModeEnabled) { return ( @@ -59,16 +69,18 @@ export const BalanceHeader = ({ } if (errorMessage) { return ( - + - + {errorMessage} - + ) } @@ -84,7 +96,14 @@ export const BalanceHeader = ({ animated={true} /> ) - }, [errorMessage, isDeveloperModeEnabled, isPrivacyModeEnabled, priceChange]) + }, [ + colors.$textDanger, + errorMessage, + isDeveloperModeEnabled, + isPrivacyModeEnabled, + onErrorPress, + priceChange + ]) const renderBalance = useCallback((): React.JSX.Element => { if (isLoading) { @@ -130,17 +149,73 @@ export const BalanceHeader = ({ renderPriceChangeIndicator ]) + const renderWalletIcon = useCallback((): JSX.Element => { + if (walletIcon === 'ledger') { + return ( + + ) + } + + return ( + + ) + }, [colors.$textSecondary, walletIcon]) + return ( - {accountName && ( - - {accountName} - - )} + + {walletName && ( + + {renderWalletIcon()} + + {walletName} + + + )} + + {accountName && ( + + {accountName} + + )} + {walletName && ( + + )} + + + {renderBalance()} ) diff --git a/packages/k2-alpine/src/theme/tokens/Icons.ts b/packages/k2-alpine/src/theme/tokens/Icons.ts index 36ba0b61ce..535ae9585b 100644 --- a/packages/k2-alpine/src/theme/tokens/Icons.ts +++ b/packages/k2-alpine/src/theme/tokens/Icons.ts @@ -1,5 +1,6 @@ import IconCheck from '../../assets/icons/check.svg' import IconExpandMore from '../../assets/icons/expand_more.svg' +import IconExpandAll from '../../assets/icons/expand_all.svg' import IconBackArrowCustom from '../../assets/icons/back_arrow_custom.svg' import IconFaceID from '../../assets/icons/face_id.svg' import IconTouchID from '../../assets/icons/touch_id.svg' @@ -240,6 +241,7 @@ export const Icons = { ChevronRight: IconChevronRight, Check: IconCheck, ExpandMore: IconExpandMore, + ExpandAll: IconExpandAll, MoreHoriz: IconMoreHoriz, Tabs: IconTabs, History: IconHistory,