diff --git a/.changeset/grumpy-rockets-pump.md b/.changeset/grumpy-rockets-pump.md new file mode 100644 index 000000000000..6e9b6681c912 --- /dev/null +++ b/.changeset/grumpy-rockets-pump.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +Add account v2 : Device selection & Scan account step diff --git a/apps/ledger-live-mobile/src/screens/ReceiveFunds/assets/lottie.json b/apps/ledger-live-mobile/src/animations/lottie.json similarity index 100% rename from apps/ledger-live-mobile/src/screens/ReceiveFunds/assets/lottie.json rename to apps/ledger-live-mobile/src/animations/lottie.json diff --git a/apps/ledger-live-mobile/src/components/RootNavigator/types/AccountSettingsNavigator.ts b/apps/ledger-live-mobile/src/components/RootNavigator/types/AccountSettingsNavigator.ts index 464d9289361c..0aaed5e36951 100644 --- a/apps/ledger-live-mobile/src/components/RootNavigator/types/AccountSettingsNavigator.ts +++ b/apps/ledger-live-mobile/src/components/RootNavigator/types/AccountSettingsNavigator.ts @@ -1,6 +1,6 @@ import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import { Account } from "@ledgerhq/types-live"; -import { ScreenName } from "~/const"; +import { NavigatorName, ScreenName } from "~/const"; export type AccountSettingsNavigatorParamList = { [ScreenName.AccountSettingsMain]: { @@ -26,4 +26,12 @@ export type AccountSettingsNavigatorParamList = { currency: CryptoCurrency; }; [ScreenName.Accounts]: { currency?: string; search?: string } | undefined; + + [NavigatorName.AccountSettings]: { + screen: string; + params: { + account?: Account; + onAccountNameChange?: (name: string, changedAccount: Account) => void; + }; + }; }; diff --git a/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx b/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx index b5a3a2182974..02c64bf828e9 100644 --- a/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx +++ b/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx @@ -19,6 +19,7 @@ const Item = ({ device, onPress }: Props) => { const wording = wired ? "usb" : available ? "available" : "unavailable"; const color = wording === "unavailable" ? "neutral.c60" : "primary.c80"; + const testID = `device-item-${device.deviceId}`; const onItemContextPress = useCallback(() => { setIsRemoveDeviceMenuOpen(true); @@ -42,7 +43,8 @@ const Item = ({ device, onPress }: Props) => { return ( onPress(device)} - touchableTestID={"device-item-" + device.deviceId} + touchableTestID={testID} + testID={testID} accessibilityRole="button" > +>["navigation"]; + const SelectableAccountsList = ({ accounts, onPressAccount, @@ -69,10 +76,7 @@ const SelectableAccountsList = ({ useFullBalance, ...props }: Props) => { - const navigation = - useNavigation< - StackNavigationProp - >(); + const navigation = useNavigation(); const onSelectAll = useCallback(() => { track("SelectAllAccounts"); @@ -85,6 +89,85 @@ const SelectableAccountsList = ({ }, [accounts, onUnselectAllProp]); const areAllSelected = accounts.every(a => selectedIds.indexOf(a.id) > -1); + const translateY = useRef(new Animated.Value(50)).current; + const opacity = useRef(new Animated.Value(0)).current; + const scale = useRef(new Animated.Value(0.8)).current; + const llmNetworkBasedAddAccountFlow = useFeature("llmNetworkBasedAddAccountFlow"); + + useEffect(() => { + if (!llmNetworkBasedAddAccountFlow?.enabled) return; + Animated.parallel([ + Animated.timing(translateY, { + toValue: 0, + duration: ANIMATION_DURATION, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 1, + duration: ANIMATION_DURATION, + useNativeDriver: true, + }), + Animated.timing(scale, { + toValue: 1, + duration: ANIMATION_DURATION, + useNativeDriver: true, + }), + ]).start(); + }, [translateY, opacity, scale, llmNetworkBasedAddAccountFlow?.enabled]); + + const animatedSelectableAccount = useMemo( + () => ({ + transform: [{ translateY }, { scale }], + opacity, + }), + [translateY, scale, opacity], + ); + + const renderSelectableAccount = useCallback( + ({ item, index }: { index: number; item: Account }) => + llmNetworkBasedAddAccountFlow?.enabled ? ( + + -1} + isDisabled={isDisabled} + onPress={onPressAccount} + useFullBalance={useFullBalance} + /> + + ) : ( + -1} + isDisabled={isDisabled} + onPress={onPressAccount} + useFullBalance={useFullBalance} + /> + ), + [ + navigation, + showHint, + listIndex, + selectedIds, + forceSelected, + isDisabled, + onAccountNameChange, + onPressAccount, + useFullBalance, + animatedSelectableAccount, + llmNetworkBasedAddAccountFlow?.enabled, + ], + ); return ( @@ -99,21 +182,12 @@ const SelectableAccountsList = ({ item.id + index} - renderItem={({ item, index }) => ( - -1} - isDisabled={isDisabled} - onPress={onPressAccount} - useFullBalance={useFullBalance} - /> + renderItem={renderSelectableAccount} + ListEmptyComponent={() => ( + + {emptyState || null} + )} - ListEmptyComponent={() => <>{emptyState || null}} /> ); @@ -127,9 +201,7 @@ type SelectableAccountProps = { showHint: boolean; rowIndex: number; listIndex: number; - navigation: StackNavigationProp< - AccountSettingsNavigatorParamList | AddAccountsNavigatorParamList - >; + navigation: NavigationProps; onAccountNameChange?: (name: string, changedAccount: Account) => void; useFullBalance?: boolean; }; @@ -147,6 +219,7 @@ const SelectableAccount = ({ useFullBalance, }: SelectableAccountProps) => { const [stopAnimation, setStopAnimation] = useState(false); + const llmNetworkBasedAddAccountFlow = useFeature("llmNetworkBasedAddAccountFlow"); const swipeableRow = useRef(null); @@ -206,11 +279,21 @@ const SelectableAccount = ({ if (!onAccountNameChange) return; swipedAccountSubject.next({ row: -1, list: -1 }); - navigation.navigate(ScreenName.EditAccountName, { - onAccountNameChange, - account, - }); - }, [account, navigation, onAccountNameChange]); + if (llmNetworkBasedAddAccountFlow?.enabled) { + navigation.navigate(NavigatorName.AccountSettings, { + screen: ScreenName.EditAccountName, + params: { + onAccountNameChange, + account, + }, + }); + } else { + navigation.navigate(ScreenName.EditAccountName, { + onAccountNameChange, + account, + }); + } + }, [account, navigation, onAccountNameChange, llmNetworkBasedAddAccountFlow?.enabled]); const renderLeftActions = useCallback( ( @@ -259,25 +342,41 @@ const SelectableAccount = ({ opacity={isDisabled ? 0.4 : 1} backgroundColor="neutral.c30" > - - - - - - - ) : null - } - /> - + {llmNetworkBasedAddAccountFlow?.enabled ? ( + + + + ) : ( + + + + + + + ) : null + } + /> + + )} + {!isDisabled && ( @@ -317,14 +416,20 @@ type HeaderProps = { const Header = ({ text, areAllSelected, onSelectAll, onUnselectAll }: HeaderProps) => { const shouldDisplaySelectAll = !!onSelectAll && !!onUnselectAll; + const llmNetworkBasedAddAccountFlow = useFeature("llmNetworkBasedAddAccountFlow"); return ( - + diff --git a/apps/ledger-live-mobile/src/const/navigation.ts b/apps/ledger-live-mobile/src/const/navigation.ts index e57a7a95944b..0f0a5dd975fd 100644 --- a/apps/ledger-live-mobile/src/const/navigation.ts +++ b/apps/ledger-live-mobile/src/const/navigation.ts @@ -541,6 +541,7 @@ export enum ScreenName { SelectDevice = "SelectDevice", SelectAccounts = "SelectAccounts", ScanDeviceAccounts = "ScanDeviceAccounts", + AddAccountsWarning = "AddAccountsWarning", } export enum NavigatorName { diff --git a/apps/ledger-live-mobile/src/icons/Pause.tsx b/apps/ledger-live-mobile/src/icons/Pause.tsx index b1a5373b5c29..3bf879c99286 100644 --- a/apps/ledger-live-mobile/src/icons/Pause.tsx +++ b/apps/ledger-live-mobile/src/icons/Pause.tsx @@ -7,10 +7,18 @@ type Props = { }; export default function Pause({ size = 16, color }: Props) { return ( - + + ); diff --git a/apps/ledger-live-mobile/src/icons/PauseCircle.tsx b/apps/ledger-live-mobile/src/icons/PauseCircle.tsx new file mode 100644 index 000000000000..7558e07badbf --- /dev/null +++ b/apps/ledger-live-mobile/src/icons/PauseCircle.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import Svg, { Path } from "react-native-svg"; + +type Props = { + size?: number; + color?: string; +}; +export default function PauseCircle({ size = 16, color }: Props) { + return ( + + + + ); +} diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index f094a73282f7..e611dace1e0a 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -4002,6 +4002,8 @@ "desc": "Are you sure you want to cancel adding accounts?" }, "imported": "Asset added successfully", + "added": "Account added to your portfolio", + "added_plural": "{{count}} Accounts added to your portfolio", "sections": { "importable": { "title": "Add existing account" @@ -4058,6 +4060,32 @@ "title": "Taproot", "desc": "Latest {{currency}} network upgrade. Will provide better privacy and cheaper fees once widespread. Still limited support from wallet and exchanges." } + }, + "scanDeviceAccounts": { + "title": "Select the accounts you want to add", + "ctaStopScan": "Stop scanning", + "confirm": "Confirm", + "scanningTitle": "Looking for any existing accounts on the Blockchain...", + "sections": { + "importable": { + "title": "We found {{count}} account", + "title_plural": "We found {{count}} accounts" + }, + "creatable": { + "title": "New account" + }, + "imported": { + "title": "Account already in the Portfolio ({{length}})", + "title_plural": "Accounts already in the Portfolio ({{length}})" + }, + "migrate": { + "title": "Accounts to update" + } + } + }, + "addAccountsSuccess": { + "ctaAddFunds": "Add funds to my account", + "ctaClose": "Close" } }, "DeviceAction": { @@ -7159,7 +7187,9 @@ "title": "Select Asset" }, "selectNetwork": { - "title": "Select the blockchain network the asset belongs to" + "title": "Select the blockchain network the asset belongs to", + "detectedAccounts": "{{count}} account (detected)", + "detectedAccounts_plural": "{{count}} accounts (detected)" } } } diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx index 9d748dd1499d..cb53e8bdf5b0 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx @@ -7,13 +7,14 @@ import { ScreenName } from "~/const"; import { getStackNavigatorConfig } from "~/navigation/navigatorConfig"; import { track } from "~/analytics"; import type { NetworkBasedAddAccountNavigator } from "LLM/features/Accounts/screens/AddAccount/types"; -import SelectAccounts from "LLM/features/Accounts/screens/SelectAccounts"; import { NavigationHeaderCloseButtonAdvanced } from "~/components/NavigationHeaderCloseButton"; import ScanDeviceAccounts from "LLM/features/Accounts/screens/ScanDeviceAccounts"; import { AccountsListNavigator } from "./screens/AccountsList/types"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import AccountsList from "LLM/features/Accounts/screens/AccountsList"; import { NavigationHeaderBackButton } from "~/components/NavigationHeaderBackButton"; +import AddAccountsSuccess from "./screens/AddAccountSuccess"; +import SelectAccounts from "./screens/SelectAccounts"; export default function Navigator() { const { colors } = useTheme(); @@ -51,23 +52,22 @@ export default function Navigator() { gestureEnabled: Platform.OS === "ios", }} > - {/* Select Accounts */} + {/* Scan accounts from device */} - - {/* Scan accounts from device */} + {/* Select Accounts */} {accountListUIFF?.enabled && ( )} + null, + }} + /> ); } diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts index dc5f7686c794..4ce5be48b8f8 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts @@ -1,19 +1,26 @@ -import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { CryptoOrTokenCurrency } from "@ledgerhq/types-cryptoassets"; import { ScreenName } from "~/const"; import { Device } from "@ledgerhq/types-devices"; +import { AccountLikeEnhanced } from "../ScanDeviceAccounts/types"; +import { Account } from "@ledgerhq/types-live"; type CommonParams = { context?: "addAccounts" | "receiveFunds"; onSuccess?: () => void; + currency: CryptoOrTokenCurrency; }; export type NetworkBasedAddAccountNavigator = { + [ScreenName.ScanDeviceAccounts]: CommonParams & { + device: Device; + inline?: boolean; + returnToSwap?: boolean; + onSuccess?: (res: { scannedAccounts: Account[]; selected: Account[] }) => void; + }; [ScreenName.SelectAccounts]: CommonParams & { - currency: CryptoCurrency | TokenCurrency; createTokenAccount?: boolean; }; - [ScreenName.ScanDeviceAccounts]: CommonParams & { - currency: CryptoCurrency | TokenCurrency; - device: Device; - onSuccess?: (_?: unknown) => void; + [ScreenName.AddAccountsSuccess]: CommonParams & { + fundedAccounts: AccountLikeEnhanced[]; + accountsWithZeroBalance: AccountLikeEnhanced[]; }; }; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccountSuccess/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccountSuccess/index.tsx new file mode 100644 index 000000000000..3113338cb956 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccountSuccess/index.tsx @@ -0,0 +1,143 @@ +import { Button, Flex, Icons, Text } from "@ledgerhq/native-ui"; +import React, { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { FlatList, ListRenderItemInfo, StyleSheet, TouchableOpacity, View } from "react-native"; +import { useTheme } from "@react-navigation/native"; +import { ScreenName } from "~/const"; +import { rgba } from "../../../../../colors"; +import { TrackScreen } from "~/analytics"; +import type { BaseComposite, StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; +import AccountItem from "../../components/AccountsListView/components/AccountItem"; +import { AccountLikeEnhanced } from "../ScanDeviceAccounts/types"; +import { Account } from "@ledgerhq/types-live"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import SafeAreaView from "~/components/SafeAreaView"; +import Circle from "~/components/Circle"; +import { NetworkBasedAddAccountNavigator } from "../AddAccount/types"; + +// TODO: Add business logic (0 funded account warning section) and Poolish (UI + gradient like figma design) & add integration tests in the following ticket https://ledgerhq.atlassian.net/browse/LIVE-13983 + +type Props = BaseComposite< + StackNavigatorProps +>; + +export default function AddAccountsSuccess({ route }: Props) { + const { colors } = useTheme(); + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + + const { currency, fundedAccounts } = route.params || {}; + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo) => ( + + + + + + + ), + [colors.primary], + ); + + const keyExtractor = useCallback((item: AccountLikeEnhanced) => item?.id, []); + + return ( + + + + + + + + + {t("addAccounts.added", { count: fundedAccounts.length })} + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + paddingHorizontal: 20, + alignItems: "center", + justifyContent: "center", + }, + currencySuccess: { + width: 80, + height: 80, + borderRadius: 40, + alignItems: "center", + justifyContent: "center", + }, + outer: { + position: "absolute", + top: -5, + right: -5, + width: 34, + height: 34, + borderRadius: 100, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + }, + inner: { + width: 26, + height: 26, + borderRadius: 100, + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + }, + title: { + marginTop: 32, + fontSize: 24, + }, + desc: { + marginTop: 16, + marginBottom: 32, + marginHorizontal: 32, + textAlign: "center", + fontSize: 14, + }, + buttonsContainer: { + alignSelf: "stretch", + }, + button: { + marginBottom: 16, + }, + iconWrapper: { + height: 72, + width: 72, + borderRadius: 50, + alignItems: "center", + justifyContent: "center", + }, +}); diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AddressTypeTooltip/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AddressTypeTooltip/index.tsx new file mode 100644 index 000000000000..906d9286ad91 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AddressTypeTooltip/index.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { View, StyleSheet, Linking } from "react-native"; +import { Trans } from "react-i18next"; + +import type { DerivationMode } from "@ledgerhq/types-live"; +import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; + +import Button from "~/components/Button"; +import QueuedDrawer from "~/components/QueuedDrawer"; +import { urls } from "~/utils/urls"; +import useAddressTypeTooltipViewModel from "./useAddressTypeTooltipViewModel"; +import { Text, Icons } from "@ledgerhq/native-ui"; + +const AddressTypeTooltip = ({ + accountSchemes, + currency, +}: { + accountSchemes: Array | null | undefined; + currency: CryptoCurrency; +}) => { + const { isOpen, onOpen, onClose, formattedAccountSchemes } = + useAddressTypeTooltipViewModel(accountSchemes); + + return ( + <> + } + onPress={onOpen} + IconRight={() => } + /> + + + + + + + + + + + {formattedAccountSchemes.map((scheme, i) => ( + + + + + + + + + ))} + {currency && currency.family === "bitcoin" ? ( + } + IconLeft={() => } + onPress={() => Linking.openURL(urls.bitcoinAddressType)} + /> + ) : null} + + + ); +}; + +const styles = StyleSheet.create({ + subtitle: { + fontSize: 14, + }, + title: { + fontSize: 16, + fontWeight: "bold", + }, + modalTitle: { + fontSize: 20, + fontWeight: "bold", + }, + modal: { + paddingHorizontal: 24, + }, + modalContainer: { + marginTop: 24, + marginBottom: 16, + alignItems: "center", + justifyContent: "center", + }, + modalRow: { + marginVertical: 16, + }, +}); + +export default AddressTypeTooltip; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AddressTypeTooltip/useAddressTypeTooltipViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AddressTypeTooltip/useAddressTypeTooltipViewModel.ts new file mode 100644 index 000000000000..d4a019788416 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AddressTypeTooltip/useAddressTypeTooltipViewModel.ts @@ -0,0 +1,22 @@ +import { useCallback, useState } from "react"; +import type { DerivationMode } from "@ledgerhq/types-live"; + +export default function useAddressTypeTooltipViewModel( + accountSchemes: Array | null | undefined, +) { + const [isOpen, setIsOpen] = useState(false); + + const onOpen = useCallback(() => { + setIsOpen(true); + }, []); + + const onClose = useCallback(() => { + setIsOpen(false); + }, []); + + const formattedAccountSchemes = accountSchemes + ? accountSchemes.map(a => (a === "" ? "legacy" : a)) + : []; + + return { isOpen, onOpen, onClose, formattedAccountSchemes }; +} diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AnimatedGradient/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AnimatedGradient/index.tsx new file mode 100644 index 000000000000..e03439e7f606 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/AnimatedGradient/index.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { useTheme } from "styled-components/native"; + +import GradientContainer from "~/components/GradientContainer"; +import Animation from "~/components/Animation"; + +import lottie from "~/animations/lottie.json"; + +function AnimatedGradient() { + const { colors } = useTheme(); + + return ( + + + + ); +} + +export default AnimatedGradient; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScanDeviceAccountsFooter/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScanDeviceAccountsFooter/index.tsx new file mode 100644 index 000000000000..cb844d333f0d --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScanDeviceAccountsFooter/index.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { StyleSheet, Animated } from "react-native"; +import { Trans } from "react-i18next"; + +import IconPause from "~/icons/Pause"; +import Button from "~/components/Button"; + +import { ScanDeviceAccountsFooterProps } from "../../types"; +import useAnimatedStyle from "./useAnimatedStyle"; + +const ScanDeviceAccountsFooter = ({ + isDisabled, + onContinue, + isScanning, + onStop, + canRetry, + canDone, + onRetry, + onDone, +}: ScanDeviceAccountsFooterProps) => { + const { animatedSelectableAccount } = useAnimatedStyle(); + + return ( + + {isScanning ? ( + } + onPress={onStop} + IconLeft={IconPause} + /> + ) : canRetry ? ( + } + onPress={onRetry} + /> + ) : canDone ? ( + } + onPress={onDone} + /> + ) : ( + } + onPress={isDisabled ? undefined : onContinue} + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + footer: { + padding: 16, + }, +}); + +export default ScanDeviceAccountsFooter; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScanDeviceAccountsFooter/useAnimatedStyle.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScanDeviceAccountsFooter/useAnimatedStyle.ts new file mode 100644 index 000000000000..c3b518defb0f --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScanDeviceAccountsFooter/useAnimatedStyle.ts @@ -0,0 +1,42 @@ +import { useEffect, useMemo, useRef } from "react"; +import { Animated } from "react-native"; + +import { ANIMATION_TIMEOUT, sharedAnimationConfiguration } from "../../constants"; + +export default function useAnimatedStyle() { + const translateY = useRef(new Animated.Value(50)).current; + const opacity = useRef(new Animated.Value(0)).current; + const scale = useRef(new Animated.Value(0.8)).current; + + useEffect(() => { + const animationTiemout = setTimeout(() => { + Animated.parallel([ + Animated.timing(translateY, { + ...sharedAnimationConfiguration, + toValue: 0, + }), + Animated.timing(opacity, { + ...sharedAnimationConfiguration, + toValue: 1, + }), + Animated.timing(scale, { + ...sharedAnimationConfiguration, + toValue: 1, + }), + ]).start(); + }, ANIMATION_TIMEOUT); + return () => { + clearTimeout(animationTiemout); + }; + }, [translateY, opacity, scale]); + + const animatedSelectableAccount = useMemo( + () => ({ + transform: [{ translateY }, { scale }], + opacity, + }), + [translateY, scale, opacity], + ); + + return { animatedSelectableAccount }; +} diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScannedAccountsSection/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScannedAccountsSection/index.tsx new file mode 100644 index 000000000000..faec63c7350b --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/components/ScannedAccountsSection/index.tsx @@ -0,0 +1,26 @@ +import React, { useEffect, useRef } from "react"; +import SelectableAccountsList from "~/components/SelectableAccountsList"; + +const ScannedAccountsSection = ({ + defaultSelected, + ...rest +}: { + defaultSelected?: boolean; +} & React.ComponentProps): JSX.Element => { + const { onSelectAll, accounts } = rest; + + /** + * unlike legacy implementation here (apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx) deleting eslint-disable-next-line react-hooks/exhaustive-deps provoked a maximum update depth error + * so we will use a call flag to prevent calling onSelectAll multiple times + * */ + const hasCalledOnSelectAll = useRef(false); + useEffect(() => { + if (defaultSelected && onSelectAll && !hasCalledOnSelectAll.current) { + onSelectAll(accounts); + hasCalledOnSelectAll.current = true; // to prevent maximum update depth error + } + }, [defaultSelected, accounts, onSelectAll]); + return ; +}; + +export default ScannedAccountsSection; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/constants.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/constants.ts new file mode 100644 index 000000000000..71db4ab326d8 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/constants.ts @@ -0,0 +1,11 @@ +import { Easing } from "react-native"; + +const ANIMATION_DURATION = 200; + +export const ANIMATION_TIMEOUT = 125; + +export const sharedAnimationConfiguration = { + duration: ANIMATION_DURATION, + useNativeDriver: true, + easing: Easing.in(Easing.cubic), +}; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx index 4085557b500c..d6ad33d0ce8b 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx @@ -1,367 +1,265 @@ -import React, { useEffect, useCallback, useState, useRef, memo } from "react"; -import { FlatList } from "react-native"; -import { concat, from } from "rxjs"; -import type { Subscription } from "rxjs"; -import { ignoreElements } from "rxjs/operators"; -import { useDispatch, useSelector } from "react-redux"; -import { useTranslation } from "react-i18next"; -import type { Account, TokenAccount } from "@ledgerhq/types-live"; -import { Currency } from "@ledgerhq/types-cryptoassets"; -import { getCurrencyBridge } from "@ledgerhq/live-common/bridge/index"; +import React from "react"; +import { StyleSheet, View, SafeAreaView } from "react-native"; +import { useSelector } from "react-redux"; +import { Trans } from "react-i18next"; +import Config from "react-native-config"; +import { useTheme } from "styled-components/native"; -import { Flex, Text } from "@ledgerhq/native-ui"; -import { makeEmptyTokenAccount } from "@ledgerhq/live-common/account/index"; +import type { DerivationMode } from "@ledgerhq/types-live"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { ScreenName } from "~/const"; +import { accountsSelector } from "~/reducers/accounts"; +import { blacklistedTokenIdsSelector } from "~/reducers/settings"; import { TrackScreen } from "~/analytics"; + import Button from "~/components/Button"; import PreventNativeBack from "~/components/PreventNativeBack"; import LText from "~/components/LText"; import RetryButton from "~/components/RetryButton"; import CancelButton from "~/components/CancelButton"; import GenericErrorBottomModal from "~/components/GenericErrorBottomModal"; -import { prepareCurrency } from "~/bridge/cache"; -import AccountCard from "~/components/AccountCard"; -import { - StackNavigatorNavigation, - StackNavigatorProps, -} from "~/components/RootNavigator/types/helpers"; -import { RootStackParamList } from "~/components/RootNavigator/types/RootNavigator"; -import Animation from "~/components/Animation"; -import lottie from "~/screens/ReceiveFunds/assets/lottie.json"; -import GradientContainer from "~/components/GradientContainer"; -import { useTheme } from "styled-components/native"; -import { walletSelector } from "~/reducers/wallet"; -import { accountNameWithDefaultSelector } from "@ledgerhq/live-wallet/store"; -import { addAccountsAction } from "@ledgerhq/live-wallet/addAccounts"; -import { accountsSelector } from "~/reducers/accounts"; -import logger from "~/logger"; -import { NetworkBasedAddAccountNavigator } from "LLM/features/Accounts/screens/AddAccount/types"; - -type Props = StackNavigatorProps; +import NavigationScrollView from "~/components/NavigationScrollView"; +import { Flex, Text, Icons } from "@ledgerhq/native-ui"; -function ScanDeviceAccounts({ navigation, route }: Props) { - const dispatch = useDispatch(); - const { t } = useTranslation(); +import useScanDeviceAccountsViewModel from "./useScanDeviceAccountsViewModel"; +import AnimatedGradient from "./components/AnimatedGradient"; +import ScanDeviceAccountsFooter from "./components/ScanDeviceAccountsFooter"; +import AddressTypeTooltip from "./components/AddressTypeTooltip"; +import ScannedAccountsSection from "./components/ScannedAccountsSection"; - const [scanning, setScanning] = useState(true); - const [addingAccount, setAddingAccount] = useState(false); - const [error, setError] = useState(null); - const [scannedAccounts, setScannedAccounts] = useState([]); - const [cancelled, setCancelled] = useState(false); +function ScanDeviceAccounts() { + const { colors } = useTheme(); const existingAccounts = useSelector(accountsSelector); - const scanSubscription = useRef(); + const blacklistedTokenIds = useSelector(blacklistedTokenIdsSelector); + + const llmNetworkBasedAddAccountFlow = useFeature("llmNetworkBasedAddAccountFlow"); const { + alreadyEmptyAccount, + alreadyEmptyAccountName, + cantCreateAccount, + CustomNoAssociatedAccounts, + error, + importAccounts, + newAccountSchemes, + noImportableAccounts, + onAccountNameChange, + onCancel, + onModalHide, + onPressAccount, + quitFlow, + restartSubscription, + scannedAccounts, + scanning, + sections, + selectAll, + selectedIds, + showAllCreatedAccounts, + stopSubscription, + unselectAll, + viewAllCreatedAccounts, currency, - device: { deviceId }, - } = route.params || {}; - - const selectAccount = useCallback( - (account: Account, currentScannedAccounts: Account[], addingAccountDelayMs?: number) => { - dispatch( - addAccountsAction({ - existingAccounts, - scannedAccounts: currentScannedAccounts, - selectedIds: [account.id], - renamings: {}, - }), - ); - if (addingAccountDelayMs) { - setTimeout(() => { - setAddingAccount(false); - // TODO: navigate to next screen to be done in the next feature - }, addingAccountDelayMs); - } else { - // TODO: navigate to next screen to be done in the next feature - } - }, - //[dispatch, navigation, route.params, existingAccounts], - [dispatch, existingAccounts], - ); - - const startSubscription = useCallback(() => { - const c = currency.type === "TokenCurrency" ? currency.parentCurrency : currency; - const bridge = getCurrencyBridge(c); - const syncConfig = { - paginationConfig: { - operations: 0, - }, - blacklistedTokenIds: [], - }; - // will be set to false if an existing account is found - // @TODO observable similar to the one in AddAccounts Flow maybe refactor both in single workflow - scanSubscription.current = concat( - from(prepareCurrency(c)).pipe(ignoreElements()), - bridge.scanAccounts({ - currency: c, - deviceId, - syncConfig, - }), - ).subscribe({ - next: ({ account }: { account: Account }) => { - if (currency.type === "TokenCurrency") { - // handle token accounts cases where we want to create empty new token accounts - const pa = { ...account }; - - if ( - !pa.subAccounts || - !pa.subAccounts.find(a => (a as TokenAccount)?.token?.id === currency.id) // in case we dont already have one we create an empty token account - ) { - const tokenAcc = makeEmptyTokenAccount(pa, currency); - const tokenA = { - ...tokenAcc, - parentAccount: pa, - }; - - pa.subAccounts = [...(pa.subAccounts || []), tokenA]; - } + returnToSwap, + } = useScanDeviceAccountsViewModel({ + existingAccounts, + blacklistedTokenIds, + }); - setScannedAccounts((accs: Account[]) => [...accs, pa]); // add the account with the newly added token account to the list of scanned accounts - } else { - setScannedAccounts((accs: Account[]) => [...accs, account]); // add the account to the list of scanned accounts - } - }, - complete: () => { - setScanning(false); - setScannedAccounts(prevScannedAccounts => { - if (prevScannedAccounts.length === 1) { - setAddingAccount(true); - selectAccount(prevScannedAccounts[0], prevScannedAccounts, 4000); - } - return prevScannedAccounts; - }); - }, - error: error => { - logger.critical(error); - setError(error); - }, - }); - }, [currency, deviceId, selectAccount]); - - const restartSubscription = useCallback(() => { - setScanning(true); - setScannedAccounts([]); - setError(null); - setCancelled(false); - startSubscription(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const stopSubscription = useCallback((syncUI = true) => { - if (scanSubscription.current) { - scanSubscription.current.unsubscribe(); - scanSubscription.current = null; - if (syncUI) { - setScanning(false); - } - } - }, []); - - const onCancel = useCallback(() => { - setError(null); - setCancelled(true); - }, []); - - const onModalHide = useCallback(() => { - if (cancelled) { - navigation.getParent>()?.pop(); - } - }, [cancelled, navigation]); - - const walletState = useSelector(walletSelector); - - const renderItem = useCallback( - ({ item: account }: { item: Account }) => { - const acc = - currency.type === "TokenCurrency" - ? account.subAccounts?.find(a => (a as TokenAccount).token.id === currency.id) - : account; - - return acc ? ( - - selectAccount(account, scannedAccounts)} - AccountSubTitle={ - currency.type === "TokenCurrency" ? ( - - {accountNameWithDefaultSelector(walletState, account)} - - ) : null - } - /> - - ) : null; - }, - [currency.id, currency.type, scannedAccounts, selectAccount, walletState], - ); - - const renderHeader = useCallback( - () => ( - - - {t("transfer.receive.selectAccount.title")} - - - {t("transfer.receive.selectAccount.subtitle", { - currencyTicker: currency.ticker, - })} - - + // Empty state same UI as ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx + const emptyTexts = { + creatable: alreadyEmptyAccount ? ( + + + {"PLACEHOLDER-1"} + {alreadyEmptyAccountName} + {"PLACEHOLDER-2"} + + + ) : CustomNoAssociatedAccounts ? ( + + ) : ( + + + {"PLACEHOLDER-1"} + {currency.name} + {"PLACEHOLDER-2"} + + ), - [currency.ticker, t], - ); - - const keyExtractor = useCallback((item: Account) => item?.id, []); - - useEffect(() => { - startSubscription(); - return () => stopSubscription(false); - }, [startSubscription, stopSubscription]); + }; return ( - <> - + + - {scanning ? ( - + {scanning || !scannedAccounts.length ? ( + + + + ) : ( + + + + )} + + + {scanning ? : null} + + {sections.map(({ id, selectable, defaultSelected, data }, i) => { + const hasMultipleSchemes = + id === "creatable" && + newAccountSchemes && + newAccountSchemes.length > 1 && + data.length > 0 && + !scanning; + return ( + + + ) : null + } + index={i} + accounts={data} + onAccountNameChange={!selectable ? undefined : onAccountNameChange} + onPressAccount={!selectable ? undefined : onPressAccount} + onSelectAll={!selectable || id === "creatable" ? undefined : selectAll} + onUnselectAll={!selectable ? undefined : unselectAll} + selectedIds={selectedIds} + emptyState={emptyTexts[id as keyof typeof emptyTexts]} + isDisabled={!selectable} + forceSelected={id === "existing"} + style={hasMultipleSchemes ? styles.smallMarginBottom : {}} + /> + {hasMultipleSchemes ? ( + + {showAllCreatedAccounts ? ( + currency.type === "CryptoCurrency" ? ( + + ) : null + ) : ( + - - ) : null} - - - + ); } -function AddingAccountLoading({ currency }: { currency: Currency }) { - const { t } = useTranslation(); - - return ( - - ); -} - -function Loading({ - children, - title, - subtitle, -}: { - children?: React.ReactNode; - title: string; - subtitle?: string; -}) { - const { colors } = useTheme(); - - return ( - <> - - - - - - - {title} - - - {subtitle} - - - {children} - - - ); -} +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: "transparent", + }, + paddingHorizontal: { + paddingHorizontal: 16, + }, + inner: { + paddingTop: 24, + backgroundColor: "transparent", + }, + innerContent: { + paddingBottom: 24, + backgroundColor: "transparent", + }, + descText: { + paddingHorizontal: 16, + marginBottom: 16, + textAlign: "center", + }, + addAccountsError: { + marginHorizontal: 16, + marginBottom: 16, + }, + button: { + flex: 1, + marginHorizontal: 8, + }, + buttonRight: { + marginLeft: 8, + }, + smallMarginBottom: { + marginBottom: 8, + }, + moreAddressTypesContainer: { + paddingHorizontal: 16, + marginBottom: 32, + }, +}); -export default memo(ScanDeviceAccounts); +export default ScanDeviceAccounts; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/types.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/types.ts new file mode 100644 index 000000000000..da72a6c2204e --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/types.ts @@ -0,0 +1,33 @@ +import { Account, SubAccount, TokenAccount } from "@ledgerhq/types-live"; +import { BaseComposite, StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; +import { NetworkBasedAddAccountNavigator } from "../AddAccount/types"; +import { ScreenName } from "~/const"; +import { AddAccountSupportLink } from "@ledgerhq/live-wallet/addAccounts"; + +export type SubAccountEnhanced = SubAccount & { + parentAccount: Account; + triggerCreateAccount: boolean; +}; + +export type AccountLikeEnhanced = SubAccountEnhanced | Account | TokenAccount; +export type ScanDeviceAccountsNavigationProps = BaseComposite< + StackNavigatorProps +>; + +export type ScanDeviceAccountsViewModelProps = { + existingAccounts: Account[]; + blacklistedTokenIds?: string[]; +}; + +export type ScanDeviceAccountsFooterProps = { + isScanning: boolean; + canRetry: boolean; + canDone: boolean; + onStop: () => void; + onContinue: () => void; + onRetry: () => void; + onDone: () => void; + isDisabled: boolean; + supportLink?: AddAccountSupportLink; + returnToSwap?: boolean; +}; diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts new file mode 100644 index 000000000000..f329363fa591 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/useScanDeviceAccountsViewModel.ts @@ -0,0 +1,286 @@ +import { useNavigation, useRoute } from "@react-navigation/core"; +import { useEffect, useCallback, useState, useRef, useMemo } from "react"; +import { concat, from, Subscription } from "rxjs"; +import { ignoreElements } from "rxjs/operators"; +import { useDispatch } from "react-redux"; +import { isAccountEmpty } from "@ledgerhq/live-common/account/index"; +import uniq from "lodash/uniq"; +import type { Account } from "@ledgerhq/types-live"; +import { getCurrencyBridge } from "@ledgerhq/live-common/bridge/index"; +import { isTokenCurrency } from "@ledgerhq/live-common/currencies/index"; +import logger from "../../../../../logger"; +import { NavigatorName, ScreenName } from "~/const"; +import { prepareCurrency } from "~/bridge/cache"; +import noAssociatedAccountsByFamily from "../../../../../generated/NoAssociatedAccounts"; +import { StackNavigatorNavigation } from "~/components/RootNavigator/types/helpers"; +import { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator"; +import { groupAddAccounts } from "@ledgerhq/live-wallet/addAccounts"; +import { useMaybeAccountName } from "~/reducers/wallet"; +import { setAccountName } from "@ledgerhq/live-wallet/store"; +import { addAccountsAction } from "@ledgerhq/live-wallet/addAccounts"; +import type { ScanDeviceAccountsNavigationProps, ScanDeviceAccountsViewModelProps } from "./types"; + +export default function useScanDeviceAccountsViewModel({ + existingAccounts, + blacklistedTokenIds, +}: ScanDeviceAccountsViewModelProps) { + const [scanning, setScanning] = useState(true); + const navigation = useNavigation(); + const [error, setError] = useState(null); + const [latestScannedAccount, setLatestScannedAccount] = useState(null); + const [scannedAccounts, setScannedAccounts] = useState([]); + const [onlyNewAccounts, setOnlyNewAccounts] = useState(true); + const [showAllCreatedAccounts, setShowAllCreatedAccounts] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); + const [cancelled, setCancelled] = useState(false); + const scanSubscription = useRef(null); + const dispatch = useDispatch(); + + const route = useRoute(); + const { + currency, + device: { deviceId }, + inline, + returnToSwap, + } = route.params || {}; + + const newAccountSchemes = useMemo(() => { + // Find accounts that are (scanned && !existing && !used) + const accountSchemes = scannedAccounts + ?.filter(a1 => !existingAccounts.map(a2 => a2.id).includes(a1.id) && !a1.used) + .map(a => a.derivationMode); + + // Make sure to return a list of unique derivationModes (i.e: avoid duplicates) + return [...new Set(accountSchemes)]; + }, [existingAccounts, scannedAccounts]); + + const preferredNewAccountScheme = useMemo( + () => (newAccountSchemes && newAccountSchemes.length > 0 ? newAccountSchemes[0] : undefined), + [newAccountSchemes], + ); + const startSubscription = useCallback(() => { + const cryptoCurrency = isTokenCurrency(currency) ? currency.parentCurrency : currency; + const bridge = getCurrencyBridge(cryptoCurrency); + const syncConfig = { + paginationConfig: { + operations: 0, + }, + blacklistedTokenIds, + }; + // will be set to false if an existing account is found + scanSubscription.current = concat( + from(prepareCurrency(cryptoCurrency)).pipe(ignoreElements()), + bridge.scanAccounts({ + currency: cryptoCurrency, + deviceId, + syncConfig, + }), + ).subscribe({ + next: ({ account }) => { + setLatestScannedAccount(account); + }, + complete: () => setScanning(false), + error: error => { + logger.critical(error); + setError(error); + }, + }); + }, [blacklistedTokenIds, currency, deviceId]); + const restartSubscription = useCallback(() => { + setScanning(true); + setScannedAccounts([]); + setSelectedIds([]); + setError(null); + setCancelled(false); + startSubscription(); + }, [startSubscription]); + const stopSubscription = useCallback((syncUI = true) => { + if (scanSubscription.current) { + scanSubscription.current.unsubscribe(); + scanSubscription.current = null; + + if (syncUI) { + setScanning(false); + } + } + }, []); + const quitFlow = useCallback(() => { + navigation.navigate(NavigatorName.Accounts); + }, [navigation]); + const onPressAccount = useCallback( + (account: Account) => { + const isChecked = selectedIds.indexOf(account.id) > -1; + const newSelectedIds = isChecked + ? selectedIds.filter(id => id !== account.id) + : [...selectedIds, account.id]; + setSelectedIds(newSelectedIds); + }, + [selectedIds], + ); + const selectAll = useCallback( + (accounts: Account[]) => { + setSelectedIds(uniq([...selectedIds, ...accounts.map(a => a.id)])); + }, + [selectedIds], + ); + const unselectAll = useCallback( + (accounts: Account[]) => { + setSelectedIds(selectedIds.filter(id => !accounts.find(a => a.id === id))); + }, + [selectedIds], + ); + const importAccounts = useCallback(() => { + const selectedAccounts = scannedAccounts.filter(a => selectedIds.includes(a.id)); + const { accountsWithZeroBalance, fundedAccounts } = selectedAccounts.reduce( + (acc, account) => { + if (account.balance.isZero()) { + acc.accountsWithZeroBalance.push(account); + } else { + acc.fundedAccounts.push(account); + } + return acc; + }, + { accountsWithZeroBalance: [], fundedAccounts: [] } as { + accountsWithZeroBalance: Account[]; + fundedAccounts: Account[]; + }, + ); + if (fundedAccounts.length > 0) { + dispatch( + addAccountsAction({ + existingAccounts, + scannedAccounts, + selectedIds: fundedAccounts.map(a => a.id), + renamings: {}, // renaming was done in scannedAccounts directly.. (see if we want later to change this paradigm) + }), + ); + } + + if (inline) { + navigation.goBack(); + } else if (navigation.replace) { + const { onSuccess } = route.params; + if (onSuccess) + onSuccess({ + scannedAccounts, + selected: scannedAccounts.filter(a => selectedIds.includes(a.id)), + }); + else + navigation.replace(ScreenName.AddAccountsSuccess, { + ...route.params, + currency, + fundedAccounts, + accountsWithZeroBalance, + }); + } + }, [ + currency, + inline, + navigation, + existingAccounts, + route.params, + scannedAccounts, + selectedIds, + dispatch, + ]); + + const onCancel = useCallback(() => { + setError(null); + setCancelled(true); + }, []); + const onModalHide = useCallback(() => { + if (cancelled) { + navigation.getParent>().pop(); + } + }, [cancelled, navigation]); + const viewAllCreatedAccounts = useCallback(() => setShowAllCreatedAccounts(true), []); + + const onAccountNameChange = useCallback( + (name: string, changedAccount: Account) => { + dispatch(setAccountName(changedAccount.id, name)); + }, + [dispatch], + ); + const { sections, alreadyEmptyAccount } = useMemo( + () => + groupAddAccounts(existingAccounts, scannedAccounts, { + scanning, + preferredNewAccountSchemes: showAllCreatedAccounts + ? undefined + : [preferredNewAccountScheme!], + }), + [ + existingAccounts, + scannedAccounts, + scanning, + showAllCreatedAccounts, + preferredNewAccountScheme, + ], + ); + const alreadyEmptyAccountName = useMaybeAccountName(alreadyEmptyAccount); + const cantCreateAccount = !sections.some(s => s.id === "creatable"); + const noImportableAccounts = !sections.some( + s => s.id === "importable" || s.id === "creatable" || s.id === "migrate", + ); + const CustomNoAssociatedAccounts = + currency.type === "CryptoCurrency" + ? noAssociatedAccountsByFamily[currency.family as keyof typeof noAssociatedAccountsByFamily] + : null; + useEffect(() => { + startSubscription(); + return () => stopSubscription(false); + }, [startSubscription, stopSubscription]); + + useEffect(() => { + if (latestScannedAccount) { + const hasAlreadyBeenScanned = scannedAccounts.some(a => latestScannedAccount.id === a.id); + const hasAlreadyBeenImported = existingAccounts.some(a => latestScannedAccount.id === a.id); + const isNewAccount = isAccountEmpty(latestScannedAccount); + + if (!isNewAccount && !hasAlreadyBeenImported) { + setOnlyNewAccounts(false); + } + + if (!hasAlreadyBeenScanned) { + setScannedAccounts([...scannedAccounts, latestScannedAccount]); + setSelectedIds( + onlyNewAccounts + ? hasAlreadyBeenImported || selectedIds.length > 0 + ? selectedIds + : [latestScannedAccount.id] + : !hasAlreadyBeenImported && !isNewAccount + ? uniq([...selectedIds, latestScannedAccount.id]) + : selectedIds, + ); + } + } + }, [existingAccounts, latestScannedAccount, onlyNewAccounts, scannedAccounts, selectedIds]); + + return { + alreadyEmptyAccount, + alreadyEmptyAccountName, + cantCreateAccount, + CustomNoAssociatedAccounts, + error, + importAccounts, + newAccountSchemes, + noImportableAccounts, + onAccountNameChange, + onCancel, + onModalHide, + onPressAccount, + quitFlow, + restartSubscription, + scannedAccounts, + scanning, + sections, + selectAll, + selectedIds, + showAllCreatedAccounts, + stopSubscription, + unselectAll, + viewAllCreatedAccounts, + returnToSwap, + currency, + }; +} diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/useSelectCryptoViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/useSelectCryptoViewModel.ts index 3abb6f2e53de..ba3ec82a2ebc 100644 --- a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/useSelectCryptoViewModel.ts +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/useSelectCryptoViewModel.ts @@ -66,8 +66,9 @@ export default function useSelectCryptoViewModel({ const isToken = curr.type === "TokenCurrency"; const currency = isToken ? curr.parentCurrency : curr; const currencyAccounts = findAccountByCurrency(accounts, currency); + const isAddAccountContext = context === "addAccounts"; - if (currencyAccounts.length > 0) { + if (currencyAccounts.length > 0 && !isAddAccountContext) { // If we found one or more accounts of the currency then we select account navigation.navigate(NavigatorName.AddAccounts, { screen: ScreenName.SelectAccounts, diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx index 2139c59bfaff..b17f5651c274 100644 --- a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx @@ -43,7 +43,7 @@ export default function SelectNetwork({ onPress={onPressItem} subTitle={ item.accounts.length > 0 - ? t("transfer.receive.selectNetwork.account", { count: item.accounts.length }) + ? t("assetSelection.selectNetwork.detectedAccounts", { count: item.accounts.length }) : "" } /> diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/useSelectNetworkViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/useSelectNetworkViewModel.ts index d35fd90da84f..c8c1f2a2bb86 100644 --- a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/useSelectNetworkViewModel.ts +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/useSelectNetworkViewModel.ts @@ -93,6 +93,20 @@ export default function useSelectNetworkViewModel({ [accounts, sortedCryptoCurrencies], ); + const navigateToDevice = useCallback( + (currency: CryptoCurrency, createTokenAccount: boolean) => { + navigation.navigate(NavigatorName.DeviceSelection, { + screen: ScreenName.SelectDevice, + params: { + currency: currency, + createTokenAccount, + context, + }, + }); + }, + [navigation, context], + ); + const processNetworkSelection = useCallback( (selectedCurrency: CryptoCurrency | TokenCurrency) => { track("network_clicked", { @@ -107,10 +121,11 @@ export default function useSelectNetworkViewModel({ ); if (!cryptoToSend) return; + const isAddAccountContext = context === "addAccounts"; const accs = findAccountByCurrency(accounts, cryptoToSend); - if (accs.length > 0) { + if (accs.length > 0 && !isAddAccountContext) { // if we found one or more accounts of the given currency we go to select account navigation.navigate(NavigatorName.AddAccounts, { screen: ScreenName.SelectAccounts, @@ -120,43 +135,34 @@ export default function useSelectNetworkViewModel({ }, }); } else if (cryptoToSend.type === "TokenCurrency") { - // cases for token currencies - const parentAccounts = findAccountByCurrency(accounts, cryptoToSend.parentCurrency); - - if (parentAccounts.length > 0) { - // if we found one or more accounts of the parent currency we select account - - navigation.navigate(NavigatorName.AddAccounts, { - screen: ScreenName.SelectAccounts, - params: { - currency: cryptoToSend, - createTokenAccount: true, - context, - }, - }); + if (isAddAccountContext) { + navigateToDevice(cryptoToSend.parentCurrency, true); } else { - // if we didn't find any account of the parent currency we add and create one - navigation.navigate(NavigatorName.DeviceSelection, { - screen: ScreenName.SelectDevice, - params: { - currency: cryptoToSend.parentCurrency, - createTokenAccount: true, - context, - }, - }); + // cases for token currencies + const parentAccounts = findAccountByCurrency(accounts, cryptoToSend.parentCurrency); + + if (parentAccounts.length > 0) { + // if we found one or more accounts of the parent currency we select account + + navigation.navigate(NavigatorName.AddAccounts, { + screen: ScreenName.SelectAccounts, + params: { + currency: cryptoToSend, + createTokenAccount: true, + context, + }, + }); + } else { + // if we didn't find any account of the parent currency we add and create one + navigateToDevice(cryptoToSend.parentCurrency, true); + } } } else { // else we create a currency account - navigation.navigate(NavigatorName.DeviceSelection, { - screen: ScreenName.SelectDevice, - params: { - currency: cryptoToSend, - context, - }, - }); + navigateToDevice(cryptoToSend, false); } }, - [accounts, navigation, provider, context], + [accounts, navigation, provider, context, navigateToDevice], ); const hideBanner = useCallback(() => { diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx index 01a35c79193d..d21a580b0c0d 100644 --- a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx @@ -3,16 +3,13 @@ import { Platform } from "react-native"; import { createStackNavigator } from "@react-navigation/stack"; import { useTheme } from "styled-components/native"; import { useTranslation } from "react-i18next"; -import { NavigationProp, useRoute } from "@react-navigation/native"; +import { useRoute } from "@react-navigation/native"; import { ScreenName } from "~/const"; import { getStackNavigatorConfig } from "~/navigation/navigatorConfig"; import { track } from "~/analytics"; import SelectDevice, { addAccountsSelectDeviceHeaderOptions, } from "LLM/features/DeviceSelection/screens/SelectDevice"; -import ConnectDevice, { - connectDeviceHeaderOptions, -} from "LLM/features/DeviceSelection/screens/ConnectDevice"; import StepHeader from "~/components/StepHeader"; import { NavigationHeaderCloseButtonAdvanced } from "~/components/NavigationHeaderCloseButton"; import { DeviceSelectionNavigatorParamsList } from "./types"; @@ -37,14 +34,6 @@ export default function Navigator() { [colors, onClose], ); - const onConnectDeviceBack = useCallback((navigation: NavigationProp>) => { - track("button_clicked", { - button: "Back arrow", - page: ScreenName.ConnectDevice, - }); - navigation.goBack(); - }, []); - return ( - {/* Select / Connect Device */} - ({ - headerTitle: () => ( - - ), - ...connectDeviceHeaderOptions(() => onConnectDeviceBack(navigation)), - })} - /> + {/*Connect Device : Only for receive flow context it will be re-added & adjusted in https://ledgerhq.atlassian.net/browse/LIVE-14726 */} ); } diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/__integrations__/deviceSelection.integration.test.tsx b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/__integrations__/deviceSelection.integration.test.tsx new file mode 100644 index 000000000000..6bdd446c66d3 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/__integrations__/deviceSelection.integration.test.tsx @@ -0,0 +1,90 @@ +import * as React from "react"; +import { render, screen } from "@tests/test-renderer"; +import DeviceSelectionNavigator from "../Navigator"; +import { useRoute, useNavigation } from "@react-navigation/native"; +import { discoverDevices } from "@ledgerhq/live-common/hw/index"; +import { DeviceModelId } from "@ledgerhq/types-devices"; +import { of } from "rxjs"; + +const MockUseRoute = useRoute as jest.Mock; +const mockNavigate = jest.fn(); +const mockDiscoverDevices = discoverDevices as jest.Mock; + +(useNavigation as jest.Mock).mockReturnValue({ + navigate: mockNavigate, + addListener: jest.fn(), +}); + +jest.mock("@ledgerhq/live-common/deposit/index", () => ({ + useGroupedCurrenciesByProvider: jest.fn(), +})); + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useRoute: jest.fn(), + useNavigation: jest.fn(), +})); + +jest.mock("@ledgerhq/live-common/hw/index", () => ({ + ...jest.requireActual("@ledgerhq/live-common/hw/index"), + discoverDevices: jest.fn(), +})); + +describe("Device Selection feature integration test", () => { + beforeAll(() => { + MockUseRoute.mockReturnValue({ + params: { + context: "addAccounts", + currency: { + type: "CryptoCurrency", + id: "bitcoin", + ticker: "BTC", + name: "Bitcoin", + family: "bitcoin", + color: "#ffae35", + decimals: 8, + managerAppName: "Bitcoin", + }, + }, + }); + }); + it("should render a device connection screen when no device is installed", () => { + mockDiscoverDevices.mockReturnValue(of({})); + render(); + + const screenTitle = screen.getByText(/Connect device/i); + const listHeader = screen.getByText(/Devices/i); + const stepIndicator = screen.getByText(/Step 2 of 3/i); + const addDeviceCTA = screen.getByText(/Add a Ledger/i); + const bottomText = screen.getByText(/Need a new Ledger?/i); + const buyNowCTA = screen.getByText(/Buy now?/i); + + [listHeader, screenTitle, stepIndicator, addDeviceCTA, bottomText, buyNowCTA].forEach( + element => { + expect(element).toBeOnTheScreen(); + }, + ); + }); + + it("should render a device selection screen when a device is installed", () => { + mockDiscoverDevices.mockReturnValue( + of({ + type: "add", + id: "usb|1", + name: "Ledger Stax device", + deviceModel: { id: DeviceModelId.stax }, + wired: true, + }), + ); + render(); + const deviceCTA = screen.getByTestId("device-item-usb|1"); + const notConnectedText = screen.getByText(/connected/i); + const addNewCTA = screen.queryByText(/Add new/i); + const bottomText = screen.getByText(/Need a new Ledger?/i); + const buyNowCTA = screen.getByText(/Buy now?/i); + + [deviceCTA, notConnectedText, bottomText, buyNowCTA, addNewCTA].forEach(element => { + expect(element).toBeOnTheScreen(); + }); + }); +}); diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/ConnectDevice/index.tsx b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/ConnectDevice/index.tsx deleted file mode 100644 index ee3400828799..000000000000 --- a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/ConnectDevice/index.tsx +++ /dev/null @@ -1,179 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { View, StyleSheet } from "react-native"; -import { useSelector } from "react-redux"; - -import { Flex } from "@ledgerhq/native-ui"; -import { - getAccountCurrency, - getMainAccount, - getReceiveFlowError, -} from "@ledgerhq/live-common/account/index"; -import type { Device } from "@ledgerhq/live-common/hw/actions/types"; - -import { accountScreenSelector } from "~/reducers/accounts"; -import { ScreenName } from "~/const"; -import { TrackScreen, track } from "~/analytics"; -import SelectDevice2, { SetHeaderOptionsRequest } from "~/components/SelectDevice2"; -import { readOnlyModeEnabledSelector } from "~/reducers/settings"; -import GenericErrorView from "~/components/GenericErrorView"; -import DeviceActionModal from "~/components/DeviceActionModal"; -// TODO: use byFamily in the next feature for device connection (scope Add account v2) -//import byFamily from "~/generated/ConnectDevice"; - -import { - ReactNavigationHeaderOptions, - StackNavigatorProps, -} from "~/components/RootNavigator/types/helpers"; -import { NavigationHeaderCloseButton } from "~/components/NavigationHeaderCloseButton"; -import { NavigationHeaderBackButton } from "~/components/NavigationHeaderBackButton"; -import { useAppDeviceAction } from "~/hooks/deviceActions"; -import ReadOnlyWarning from "~/screens/ReceiveFunds/ReadOnlyWarning"; -import NotSyncedWarning from "~/screens/ReceiveFunds/NotSyncedWarning"; -import { DeviceSelectionNavigatorParamsList } from "../../types"; -// TODO: use SkipSelectDevice in the next feature for device connection if needed (scope Add account v2) -//import SkipSelectDevice from "~/screens/SkipSelectDevice"; - -// Defines some of the header options for this screen to be able to reset back to them. -export const connectDeviceHeaderOptions = ( - onHeaderBackButtonPress: () => void, -): ReactNavigationHeaderOptions => ({ - headerRight: () => , - headerLeft: () => , -}); - -export default function ConnectDevice({ - navigation, - route, -}: StackNavigatorProps) { - const { account, parentAccount } = useSelector(accountScreenSelector(route)); - const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); - const [device, setDevice] = useState(); - const action = useAppDeviceAction(); - - useEffect(() => { - const readOnlyTitle = "transfer.receive.titleReadOnly"; - if (readOnlyModeEnabled && route.params?.title !== readOnlyTitle) { - navigation.setParams({ - title: readOnlyTitle, - }); - } - }, [navigation, readOnlyModeEnabled, route.params]); - - const error = useMemo( - () => (account ? getReceiveFlowError(account, parentAccount) : null), - [account, parentAccount], - ); - - const onResult = () => { - // TODO: implement business logic for both Add account v2 and Receive flow - }; - - const onSkipDevice = useCallback(() => { - if (!account) return; - // TODO: implement business logic for both Add account v2 and Receive flow - }, [account]); - - const onClose = useCallback(() => { - setDevice(undefined); - }, []); - - const onHeaderBackButtonPress = useCallback(() => { - track("button_clicked", { - button: "Back arrow", - page: ScreenName.ReceiveConnectDevice, - }); - navigation.goBack(); - }, [navigation]); - - // Reacts from request to update the screen header - const requestToSetHeaderOptions = useCallback( - (request: SetHeaderOptionsRequest) => { - if (request.type === "set") { - navigation.setOptions({ - headerLeft: request.options.headerLeft, - headerRight: request.options.headerRight, - }); - } else { - // Sets back the header to its initial values set for this screen - navigation.setOptions({ - ...connectDeviceHeaderOptions(onHeaderBackButtonPress), - }); - } - }, - [navigation, onHeaderBackButtonPress], - ); - - if (!account) return null; - - if (error) { - return ( - - - - ); - } - - const mainAccount = getMainAccount(account, parentAccount); - const currency = getAccountCurrency(mainAccount); - if (currency.type !== "CryptoCurrency") return null; // this should not happen: currency of main account is a crypto currency - const tokenCurrency = account && account.type === "TokenAccount" ? account.token : undefined; - - // check for coin specific UI - // TODO: implement business logic for both Add account v2 and Receive flow - //const CustomConnectDevice = byFamily[currency.family as keyof typeof byFamily]; - //if (CustomConnectDevice) return ; - - if (readOnlyModeEnabled) { - return ; - } - - if (!mainAccount.freshAddress) { - return ; - } - - return ( - <> - - {/* - * TODO: implement business logic for both Add account v2 and Receive flow - - */} - - - - setDevice(undefined)} - analyticsPropertyFlow="receive" - /> - - ); -} - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - bodyError: { - flex: 1, - flexDirection: "column", - alignSelf: "center", - justifyContent: "center", - alignItems: "center", - paddingBottom: 16, - }, - scroll: { - flex: 1, - }, - scrollContainer: { - padding: 16, - }, -}); diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx index 94ce84697635..ae2c0b946f99 100644 --- a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx @@ -4,7 +4,7 @@ import { Flex } from "@ledgerhq/native-ui"; import { useTheme } from "@react-navigation/native"; import { ScreenName } from "~/const"; import { track } from "~/analytics"; -import SelectDevice2, { SetHeaderOptionsRequest } from "~/components/SelectDevice2"; +import DeviceSelector, { SetHeaderOptionsRequest } from "~/components/SelectDevice2"; import DeviceActionModal from "~/components/DeviceActionModal"; import { @@ -16,6 +16,7 @@ import { NavigationHeaderBackButton } from "~/components/NavigationHeaderBackBut import { DeviceSelectionNavigatorParamsList } from "../../types"; import { NetworkBasedAddAccountNavigator } from "LLM/features/Accounts/screens/AddAccount/types"; import useSelectDeviceViewModel from "./useSelectDeviceViewModel"; +import SkipSelectDevice from "~/screens/SkipSelectDevice"; // Defines some of the header options for this screen to be able to reset back to them. export const addAccountsSelectDeviceHeaderOptions = ( @@ -70,12 +71,9 @@ export default function SelectDevice({ }, ]} > - {/* - TODO: should be rendered only on receive flow context -> TO BE DONE After delivering the add account flow - - */} + - void; - }; [ScreenName.SelectDevice]: SelectDeviceRouteParams; [NavigatorName.AddAccounts]?: Partial>; }; diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationLoading.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationLoading.tsx index 9ea8535f7633..92c968853081 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationLoading.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/Activation/ActivationLoading.tsx @@ -8,7 +8,7 @@ import { AnalyticsPage } from "../../hooks/useLedgerSyncAnalytics"; import GradientContainer from "~/components/GradientContainer"; import Animation from "~/components/Animation"; import { Flex, Text } from "@ledgerhq/native-ui"; -import lottie from "~/screens/ReceiveFunds/assets/lottie.json"; +import lottie from "~/animations/lottie.json"; import { useTheme } from "styled-components/native"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; diff --git a/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx b/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx index 570aece13b10..8f8af0662fb8 100644 --- a/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx +++ b/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx @@ -24,7 +24,7 @@ import Button from "~/components/Button"; import PreventNativeBack from "~/components/PreventNativeBack"; import SelectableAccountsList from "~/components/SelectableAccountsList"; import LiveLogo from "~/icons/LiveLogoIcon"; -import IconPause from "~/icons/Pause"; +import IconPause from "~/icons/PauseCircle"; import ExternalLink from "~/icons/ExternalLink"; import Chevron from "~/icons/Chevron"; import Info from "~/icons/Info"; diff --git a/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccount.tsx b/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccount.tsx index 2a61d7da1f59..5edc1683299c 100644 --- a/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccount.tsx +++ b/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccount.tsx @@ -29,7 +29,7 @@ import { } from "~/components/RootNavigator/types/helpers"; import { RootStackParamList } from "~/components/RootNavigator/types/RootNavigator"; import Animation from "~/components/Animation"; -import lottie from "./assets/lottie.json"; +import lottie from "~/animations/lottie.json"; import GradientContainer from "~/components/GradientContainer"; import { useTheme } from "styled-components/native"; import { walletSelector } from "~/reducers/wallet"; diff --git a/apps/ledger-live-mobile/src/screens/SkipSelectDevice.tsx b/apps/ledger-live-mobile/src/screens/SkipSelectDevice.tsx index ab38a3d45a6f..3ab0a5c281f9 100644 --- a/apps/ledger-live-mobile/src/screens/SkipSelectDevice.tsx +++ b/apps/ledger-live-mobile/src/screens/SkipSelectDevice.tsx @@ -10,11 +10,13 @@ import { ReceiveFundsStackParamList } from "~/components/RootNavigator/types/Rec import { ScreenName } from "~/const"; import { useDebouncedRequireBluetooth } from "~/components/RequiresBLE/hooks/useRequireBluetooth"; import RequiresBluetoothDrawer from "~/components/RequiresBLE/RequiresBluetoothDrawer"; +import { DeviceSelectionNavigatorParamsList } from "~/newArch/features/DeviceSelection/types"; type Navigation = | StackNavigatorProps | StackNavigatorProps - | StackNavigatorProps; + | StackNavigatorProps + | StackNavigatorProps; type Props = { onResult: (device: Device) => void;