feat(suite-native): Mobile Trade: Country of residence settings screen

This commit is contained in:
Jirka Bažant
2025-10-22 16:39:10 +02:00
committed by Jiří Bažant
parent f8d35cf591
commit 0805b13ec8
39 changed files with 510 additions and 103 deletions

View File

@@ -87,6 +87,7 @@
"@suite-native/test-utils": "workspace:*",
"@suite-native/theme": "workspace:*",
"@suite-native/toasts": "workspace:*",
"@suite-native/trading-residence": "workspace:*",
"@suite-native/transactions": "workspace:*",
"@trezor/blockchain-link-types": "workspace:*",
"@trezor/bundler-security": "workspace:*",

View File

@@ -36,6 +36,10 @@ import {
} from '@suite-native/navigation';
import { ReceiveStackNavigator } from '@suite-native/receive';
import { selectIsOnboardingFinished } from '@suite-native/settings';
import {
TradingLocationModalScreen,
selectShouldDisplayTradingResidenceOnboarding,
} from '@suite-native/trading-residence';
import { TransactionDetailScreen } from '@suite-native/transactions';
import { AppTabNavigator } from './AppTabNavigator';
@@ -45,13 +49,20 @@ const RootStack = createNativeStackNavigator<RootStackParamList>();
export const RootStackNavigator = () => {
const isOnboardingFinished = useSelector(selectIsOnboardingFinished);
const shouldDisplayTradingResidenceOnboarding = useSelector(
selectShouldDisplayTradingResidenceOnboarding,
);
const getInitialRouteName = () => {
if (isOnboardingFinished) {
return RootStackRoutes.AppTabs;
if (!isOnboardingFinished) {
return RootStackRoutes.OnboardingStack;
}
return RootStackRoutes.OnboardingStack;
if (shouldDisplayTradingResidenceOnboarding) {
return RootStackRoutes.TradingLocationModal;
}
return RootStackRoutes.AppTabs;
};
return (
@@ -157,6 +168,10 @@ export const RootStackNavigator = () => {
name={RootStackRoutes.TradingWebView}
component={TradingWebViewScreen}
/>
<RootStack.Screen
name={RootStackRoutes.TradingLocationModal}
component={TradingLocationModalScreen}
/>
</RootStack.Group>
</RootStack.Navigator>
);

View File

@@ -36,6 +36,7 @@ describe('AppTabNavigator', () => {
[FeatureFlag.IsTradingBuyEnabled]: false,
[FeatureFlag.IsTradingExchangeEnabled]: false,
[FeatureFlag.IsTradingSellEnabled]: false,
[FeatureFlag.IsTradingResidenceCheckEnabled]: false,
},
messageSystem: {
validMessages: {
@@ -80,6 +81,7 @@ describe('AppTabNavigator', () => {
featureFlags: {
...featureFlagsInitialState,
[FeatureFlag.IsTradingBuyEnabled]: true,
[FeatureFlag.IsTradingResidenceCheckEnabled]: false,
},
});

View File

@@ -80,6 +80,7 @@
{ "path": "../test-utils" },
{ "path": "../theme" },
{ "path": "../toasts" },
{ "path": "../trading-residence" },
{ "path": "../transactions" },
{
"path": "../../packages/blockchain-link-types"

View File

@@ -13,6 +13,8 @@
"dependencies": {
"@reduxjs/toolkit": "2.9.1",
"@suite-native/config": "workspace:*",
"@trezor/env-utils": "workspace:*",
"react-native": "0.81.4",
"react-redux": "9.2.0"
}
}

View File

@@ -1,11 +1,36 @@
import { Platform } from 'react-native';
describe('featureFlagsSlice', () => {
afterEach(() => {
Platform.OS = 'ios';
jest.resetModules();
jest.resetAllMocks();
});
describe('initial state', () => {
it('should have correct initial state', () => {
it('should have correct initial state on iOS', () => {
Platform.OS = 'ios';
const { featureFlagsReducer } = require('../featureFlagsSlice');
const initialState = featureFlagsReducer(undefined, { type: 'undefined_action' });
expect(initialState).toEqual({
areDebugOnlyNetworksEnabled: false,
areExperimentalOnlyNetworksEnabled: false,
areTradingExchangeDexesEnabled: false,
isCardanoSendEnabled: false,
isDebugKeysAllowed: false,
isLocalFirstStorageEnabled: false,
isLocalizationEnabled: false,
isTradingBuyEnabled: false,
isTradingExchangeEnabled: false,
isTradingResidenceCheckEnabled: true,
isTradingSellEnabled: false,
});
});
it('should have correct initial state on android', () => {
Platform.OS = 'android';
const { featureFlagsReducer } = require('../featureFlagsSlice');
const initialState = featureFlagsReducer(undefined, { type: 'undefined_action' });

View File

@@ -1,5 +1,7 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { isIOs } from '@trezor/env-utils';
export const FeatureFlag = {
AreDebugOnlyNetworksEnabled: 'areDebugOnlyNetworksEnabled',
AreExperimentalOnlyNetworksEnabled: 'areExperimentalOnlyNetworksEnabled',
@@ -38,7 +40,8 @@ export const featureFlagsInitialState: FeatureFlagsState = {
[FeatureFlag.AreTradingExchangeDexesEnabled]:
process.env.EXPO_PUBLIC_FF_ARE_TRADING_EXCHANGE_DEXES_ENABLED === 'true',
[FeatureFlag.IsTradingResidenceCheckEnabled]:
process.env.EXPO_PUBLIC_FF_IS_TRADING_RESIDENCE_CHECK_ENABLED === 'true',
process.env.EXPO_PUBLIC_FF_IS_TRADING_RESIDENCE_CHECK_ENABLED === 'true' ||
(isIOs() && process.env.EXPO_PUBLIC_FF_IS_TRADING_RESIDENCE_CHECK_ENABLED !== 'false'),
[FeatureFlag.IsLocalizationEnabled]:
process.env.EXPO_PUBLIC_FF_IS_LOCALIZATION_ENABLED === 'true',
[FeatureFlag.IsLocalFirstStorageEnabled]:

View File

@@ -1,5 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "libDev" },
"references": [{ "path": "../config" }]
"references": [
{ "path": "../config" },
{ "path": "../../packages/env-utils" }
]
}

View File

@@ -0,0 +1,5 @@
const baseConfig = require('../../jest.config.native');
module.exports = {
...baseConfig,
};

View File

@@ -7,7 +7,8 @@
"main": "src/index",
"scripts": {
"depcheck": "yarn g:depcheck",
"type-check": "yarn g:tsc --build"
"type-check": "yarn g:tsc --build",
"test:unit": "yarn g:jest --coverage"
},
"dependencies": {
"@react-navigation/core": "7.12.4",
@@ -21,6 +22,7 @@
"@suite-native/link": "workspace:*",
"@suite-native/navigation": "workspace:*",
"@suite-native/settings": "workspace:*",
"@suite-native/trading-residence": "workspace:*",
"@trezor/env-utils": "workspace:*",
"@trezor/styles": "workspace:*",
"@trezor/theme": "workspace:*",
@@ -31,5 +33,8 @@
"react-native": "0.81.4",
"react-native-reanimated": "~3.19.3",
"react-redux": "9.2.0"
},
"devDependencies": {
"@suite-native/test-utils": "workspace:*"
}
}

View File

@@ -0,0 +1,59 @@
import { HomeStackRoutes, RootStackRoutes } from '@suite-native/navigation';
import { setIsOnboardingFinished } from '@suite-native/settings';
import {
TestStore,
act,
initStore,
renderHookWithStoreProviderAsync,
} from '@suite-native/test-utils';
import { useExitOnboardingFlow } from '../useExitOnboardingFlow';
const mockNavigationDispatch = jest.fn();
jest.mock('@react-navigation/core', () => ({
...jest.requireActual('@react-navigation/core'),
useNavigation: () => ({
dispatch: mockNavigationDispatch,
}),
}));
describe('useExitOnboardingFlow', () => {
let store: TestStore;
const renderUseExitOnboardingFlow = () =>
renderHookWithStoreProviderAsync(() => useExitOnboardingFlow(), { store });
beforeEach(async () => {
store = await initStore();
jest.clearAllMocks();
});
it('should set onboarding flag and navigate', async () => {
const dispatchSpy = jest.spyOn(store, 'dispatch');
const { result } = await renderUseExitOnboardingFlow();
// call the returned callback
act(() => {
result.current();
});
// assert that onboarding finished action was dispatched
expect(dispatchSpy).toHaveBeenCalledWith(setIsOnboardingFinished());
// assert that navigation.reset was dispatched to navigate to AppTabs -> Home
expect(mockNavigationDispatch).toHaveBeenCalledTimes(1);
expect(mockNavigationDispatch).toHaveBeenCalledWith({
payload: {
index: 0,
routes: [
{
name: RootStackRoutes.AppTabs,
params: { screen: HomeStackRoutes.Home },
},
],
},
type: 'RESET',
});
});
});

View File

@@ -0,0 +1,29 @@
import { useDispatch } from 'react-redux';
import { CommonActions, useNavigation } from '@react-navigation/core';
import { HomeStackRoutes, RootStackRoutes } from '@suite-native/navigation';
import { setIsOnboardingFinished } from '@suite-native/settings';
export const useExitOnboardingFlow = () => {
const dispatch = useDispatch();
const navigation = useNavigation();
return () => {
dispatch(setIsOnboardingFinished());
// TODO: COSMETIC IMPROVEMENT: redirect to home only if there is no device connected. In case of device connected,
// the redirect is handled in useHandleDeviceConnection hook. in reaction to the `setIsOnboardingFinished` call.
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [
{
name: RootStackRoutes.AppTabs,
params: { screen: HomeStackRoutes.Home },
},
],
}),
);
};
};

View File

@@ -8,6 +8,7 @@ import {
import { AnalyticsConsentScreen } from '../screens/AnalyticsConsentScreen';
import { BiometricsScreen } from '../screens/BiometricsScreen';
import { TradingLocationScreen } from '../screens/TradingLocationScreen';
import { WelcomeScreen } from '../screens/WelcomeScreen';
export const OnboardingStack = createNativeStackNavigator<OnboardingStackParamList>();
@@ -26,5 +27,9 @@ export const OnboardingStackNavigator = () => (
name={OnboardingStackRoutes.Biometrics}
component={BiometricsScreen}
/>
<OnboardingStack.Screen
name={OnboardingStackRoutes.TradingLocation}
component={TradingLocationScreen}
/>
</OnboardingStack.Navigator>
);

View File

@@ -1,6 +1,4 @@
import { useDispatch } from 'react-redux';
import { CommonActions } from '@react-navigation/core';
import { useSelector } from 'react-redux';
import { EventType, analytics } from '@suite-native/analytics';
import { Box, Button, HStack, Text, VStack } from '@suite-native/atoms';
@@ -8,29 +6,33 @@ import { BiometricsSvg, useBiometricsSettings } from '@suite-native/biometrics';
import { Icon } from '@suite-native/icons';
import { Translation } from '@suite-native/intl';
import {
HomeStackRoutes,
OnboardingStackParamList,
OnboardingStackRoutes,
RootStackRoutes,
Screen,
ScreenHeader,
StackProps,
} from '@suite-native/navigation';
import { setIsOnboardingFinished } from '@suite-native/settings';
import { selectIsTradingResidenceCheckEnabled } from '@suite-native/trading-residence';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { useExitOnboardingFlow } from '../hooks/useExitOnboardingFlow';
export type BiometricsScreenProps = StackProps<
OnboardingStackParamList,
OnboardingStackRoutes.Biometrics
>;
const titleStyle = prepareNativeStyle(_ => ({
// this title should have smaller letter spacing by design.
letterSpacing: -1.4,
}));
export const BiometricsScreen = ({
navigation,
}: StackProps<OnboardingStackParamList, OnboardingStackRoutes.Biometrics>) => {
export const BiometricsScreen = ({ navigation }: BiometricsScreenProps) => {
const { applyStyle } = useNativeStyles();
const { toggleBiometricsOption } = useBiometricsSettings();
const exitOnboardingFlow = useExitOnboardingFlow();
const dispatch = useDispatch();
const shouldDisplayTradingLocationScreen = useSelector(selectIsTradingResidenceCheckEnabled);
const enableBiometrics = async () => {
const result = await toggleBiometricsOption();
@@ -45,31 +47,21 @@ export const BiometricsScreen = ({
}
};
const exitOnboardingFlow = () => {
dispatch(setIsOnboardingFinished());
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [
{
name: RootStackRoutes.AppTabs,
params: {
screen: HomeStackRoutes.Home,
},
},
],
}),
);
const handleRedirect = () => {
if (shouldDisplayTradingLocationScreen) {
navigation.navigate(OnboardingStackRoutes.TradingLocation);
} else {
exitOnboardingFlow();
}
};
const handleEnableButtonPress = async () => {
await enableBiometrics();
exitOnboardingFlow();
handleRedirect();
};
const handleNotNowButtonPress = () => {
exitOnboardingFlow();
handleRedirect();
};
return (

View File

@@ -0,0 +1,16 @@
import { Screen, ScreenHeader } from '@suite-native/navigation';
import { OnboardingButtons, TradingLocationSettings } from '@suite-native/trading-residence';
import { useExitOnboardingFlow } from '../hooks/useExitOnboardingFlow';
export const TradingLocationScreen = () => {
const exitOnboardingFlow = useExitOnboardingFlow();
return (
<Screen header={<ScreenHeader />}>
<TradingLocationSettings context="onboarding">
<OnboardingButtons afterPress={exitOnboardingFlow} />
</TradingLocationSettings>
</Screen>
);
};

View File

@@ -0,0 +1,74 @@
import { OnboardingStackRoutes } from '@suite-native/navigation';
import {
TestStore,
initStore,
renderWithStoreProviderAsync,
userEvent,
} from '@suite-native/test-utils';
import { BiometricsScreen, BiometricsScreenProps } from '../BiometricsScreen';
const mockNavigate = jest.fn();
const mockNavigationDispatch = jest.fn();
const mockRoute = {
key: 'BiometricsScreen',
name: OnboardingStackRoutes.Biometrics,
params: undefined,
} as const;
jest.mock('@react-navigation/core', () => ({
...jest.requireActual('@react-navigation/core'),
useNavigation: () => ({
navigate: mockNavigate,
dispatch: mockNavigationDispatch,
}),
useRoute: () => mockRoute,
}));
describe('BiometricsScreen', () => {
let store: TestStore;
const renderBiometricsScreen = () =>
renderWithStoreProviderAsync(
<BiometricsScreen
navigation={
{ navigate: mockNavigate } as unknown as BiometricsScreenProps['navigation']
}
route={mockRoute}
/>,
{ store },
);
beforeEach(() => {
jest.clearAllMocks();
});
it('should redirect to TradingLocation screen on Skip press when isTradingResidenceCheckEnabled is set to true', async () => {
store = await initStore({
featureFlags: {
isTradingResidenceCheckEnabled: true,
},
});
const { getByText } = await renderBiometricsScreen();
await userEvent.press(getByText('Not now'));
expect(mockNavigate).toHaveBeenCalledWith(OnboardingStackRoutes.TradingLocation);
});
it('should redirect to Home screen on Skip press when isTradingResidenceCheckEnabled is set to false', async () => {
store = await initStore({
featureFlags: {
isTradingResidenceCheckEnabled: false,
},
});
const { getByText } = await renderBiometricsScreen();
await userEvent.press(getByText('Not now'));
expect(mockNavigationDispatch).toHaveBeenCalledWith({
payload: { index: 0, routes: [{ name: 'AppTabs', params: { screen: 'Home' } }] },
type: 'RESET',
});
});
});

View File

@@ -1,25 +1,32 @@
import { RouteProp } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/core';
import { EventType, analytics } from '@suite-native/analytics';
import { TradingStackParamList, TradingStackRoutes } from '@suite-native/navigation';
import { renderWithStoreProviderAsync, userEvent } from '@suite-native/test-utils';
import { TradingLocationOnboardingScreen } from '../TradingLocationOnboardingScreen';
import { TradingLocationScreen } from '../TradingLocationScreen';
const mockExitOnboardingFlow = jest.fn();
jest.mock('@react-navigation/core', () => ({
...jest.requireActual('@react-navigation/core'),
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useRoute: () =>
({
params: undefined,
}) as RouteProp<TradingStackParamList, TradingStackRoutes.TradingHistory>,
}));
jest.mock('../../hooks/useExitOnboardingFlow', () => ({
useExitOnboardingFlow: () => mockExitOnboardingFlow,
}));
describe('TradingLocationOnboardingScreen', () => {
const renderTradingLocationOnboardingScreen = () =>
renderWithStoreProviderAsync(<TradingLocationOnboardingScreen />);
const renderTradingLocationScreen = () =>
renderWithStoreProviderAsync(<TradingLocationScreen />);
it('should render all components', async () => {
const { getByText, getByLabelText } = await renderTradingLocationOnboardingScreen();
const { getByText, getByLabelText } = await renderTradingLocationScreen();
expect(getByText('Confirm your location to enable trading')).toBeOnTheScreen();
expect(getByText('Confirm location')).toBeOnTheScreen();
@@ -30,7 +37,7 @@ describe('TradingLocationOnboardingScreen', () => {
it('should log analytics event on country change', async () => {
const analyticsSpy = jest.spyOn(analytics, 'report');
const { getByText } = await renderTradingLocationOnboardingScreen();
const { getByText } = await renderTradingLocationScreen();
await userEvent.press(getByText('Country of residence'));
await userEvent.press(getByText('🇦🇷 Argentina'));
@@ -44,4 +51,11 @@ describe('TradingLocationOnboardingScreen', () => {
},
});
});
it('should use exitOnboardingFlow on button press', async () => {
const { getByText } = await renderTradingLocationScreen();
await userEvent.press(getByText('Not now'));
expect(mockExitOnboardingFlow).toHaveBeenCalledTimes(1);
});
});

View File

@@ -10,10 +10,12 @@
{ "path": "../link" },
{ "path": "../navigation" },
{ "path": "../settings" },
{ "path": "../trading-residence" },
{ "path": "../../packages/env-utils" },
{ "path": "../../packages/styles" },
{ "path": "../../packages/theme" },
{ "path": "../../packages/urls" },
{ "path": "../../packages/utils" }
{ "path": "../../packages/utils" },
{ "path": "../test-utils" }
]
}

View File

@@ -0,0 +1,15 @@
import { FeatureFlag, featureFlagsInitialState } from '@suite-native/feature-flags';
export const residenceCheckDisabledState = {
featureFlags: {
...featureFlagsInitialState,
[FeatureFlag.IsTradingResidenceCheckEnabled]: false,
},
};
export const residenceCheckEnabledState = {
featureFlags: {
...featureFlagsInitialState,
[FeatureFlag.IsTradingResidenceCheckEnabled]: true,
},
};

View File

@@ -6,6 +6,7 @@ import {
renderWithStoreProviderAsync,
} from '@suite-native/test-utils';
import { residenceCheckDisabledState } from '../../../__fixtures__/residenceCheckState';
import { btcAsset } from '../../../__fixtures__/tradeableAssets';
import { getInitializedTradingState } from '../../../__fixtures__/tradingState';
import { useBuyForm } from '../../../hooks/buy/useBuyForm';
@@ -27,8 +28,11 @@ describe('BuyForm', () => {
});
it('should render when buy data are not preloaded', async () => {
const { result } = await renderFormHook({});
const { queryByText, getByText, getByLabelText } = await renderBuyForm({}, result.current);
const { result } = await renderFormHook(residenceCheckDisabledState);
const { queryByText, getByText, getByLabelText } = await renderBuyForm(
residenceCheckDisabledState,
result.current,
);
expect(getByText('You pay')).toBeTruthy();
expect(getByLabelText('Select asset')).toHaveTextContent(/Select asset/);
@@ -43,7 +47,10 @@ describe('BuyForm', () => {
describe('with preloaded buy data', () => {
let form: BuyFormType;
const preloadedState = { wallet: { trading: getInitializedTradingState() } };
const preloadedState = {
wallet: { trading: getInitializedTradingState() },
...residenceCheckDisabledState,
};
beforeEach(async () => {
const { result } = await renderFormHook(preloadedState);

View File

@@ -29,6 +29,7 @@ describe('Header', () => {
[FeatureFlag.IsTradingExchangeEnabled]: exchangeEnabled,
[FeatureFlag.IsTradingSellEnabled]: sellEnabled,
[FeatureFlag.AreTradingExchangeDexesEnabled]: areTradingExchangeDexesEnabled,
[FeatureFlag.IsTradingResidenceCheckEnabled]: false,
},
});

View File

@@ -1,7 +1,6 @@
import { TradingCountryOption } from '@suite-common/trading';
import { yup } from '@suite-common/validators';
import { EventType, analytics } from '@suite-native/analytics';
import { FeatureFlag } from '@suite-native/feature-flags';
import { Form, useForm } from '@suite-native/forms';
import type { UseFormReturn } from '@suite-native/forms';
import {
@@ -12,14 +11,19 @@ import {
userEvent,
} from '@suite-native/test-utils';
import {
residenceCheckDisabledState,
residenceCheckEnabledState,
} from '../../../__fixtures__/residenceCheckState';
import { TradingCountryOfResidencePicker } from '../TradingCountryOfResidencePicker';
describe('TradingCountryOfResidencePicker', () => {
let form: UseFormReturn<{ country: TradingCountryOption }>;
const renderForm = () =>
renderHookWithStoreProviderAsync(() =>
useForm<{ country: TradingCountryOption }>({ validation: yup.object() }),
renderHookWithStoreProviderAsync(
() => useForm<{ country: TradingCountryOption }>({ validation: yup.object() }),
{ preloadedState: residenceCheckDisabledState },
);
const renderCountryOfResidencePicker = (preloadedState: PreloadedState) =>
@@ -41,7 +45,7 @@ describe('TradingCountryOfResidencePicker', () => {
form.setValue('country', { value: 'US', label: 'United States' });
});
const { getByTestId } = await renderCountryOfResidencePicker({});
const { getByTestId } = await renderCountryOfResidencePicker(residenceCheckDisabledState);
expect(getByTestId('testID/value')).toHaveTextContent('United States');
});
@@ -49,7 +53,7 @@ describe('TradingCountryOfResidencePicker', () => {
it('should call analytics on country change', async () => {
const reportSpy = jest.spyOn(analytics, 'report');
const { getByText } = await renderCountryOfResidencePicker({});
const { getByText } = await renderCountryOfResidencePicker(residenceCheckDisabledState);
await userEvent.press(getByText('Country of residence'));
await userEvent.press(getByText(/Algeria/));
@@ -64,9 +68,7 @@ describe('TradingCountryOfResidencePicker', () => {
});
it('should render nothing when isTradingResidenceCheckEnabled FF is true', async () => {
const { toJSON } = await renderCountryOfResidencePicker({
featureFlags: { [FeatureFlag.IsTradingResidenceCheckEnabled]: true },
});
const { toJSON } = await renderCountryOfResidencePicker(residenceCheckEnabledState);
expect(toJSON()).toBeNull();
});

View File

@@ -7,6 +7,7 @@ import {
} from '@suite-native/test-utils';
import { getBtcAccount } from '../../../__fixtures__/account';
import { residenceCheckDisabledState } from '../../../__fixtures__/residenceCheckState';
import { sellQuotes } from '../../../__fixtures__/sellQuotes';
import { btcAsset } from '../../../__fixtures__/tradeableAssets';
import { getInitializedTradingState } from '../../../__fixtures__/tradingState';
@@ -42,7 +43,10 @@ describe('SellForm', () => {
let preloadedState: PreloadedState;
beforeEach(async () => {
preloadedState = { wallet: { trading: getInitializedTradingState() } };
preloadedState = {
wallet: { trading: getInitializedTradingState() },
...residenceCheckDisabledState,
};
preloadedState.wallet!.trading!.sell!.quotes = sellQuotes;
const { result } = await renderFormHook(preloadedState);

View File

@@ -18,6 +18,7 @@ describe('TradingStackNavigator', () => {
featureFlags: {
...featureFlagsInitialState,
[FeatureFlag.IsTradingBuyEnabled]: true,
[FeatureFlag.IsTradingResidenceCheckEnabled]: false,
},
},
});

View File

@@ -43,6 +43,7 @@ const stateWithEnabledBuy = {
featureFlags: {
...featureFlagsInitialState,
[FeatureFlag.IsTradingBuyEnabled]: true,
[FeatureFlag.IsTradingResidenceCheckEnabled]: false,
},
};
@@ -50,6 +51,7 @@ const stateWithDisabledTrading = {
featureFlags: {
...featureFlagsInitialState,
[FeatureFlag.IsTradingBuyEnabled]: false,
[FeatureFlag.IsTradingResidenceCheckEnabled]: false,
},
messageSystem: {
validMessages: {

View File

@@ -134,6 +134,7 @@ export type OnboardingStackParamList = {
[OnboardingStackRoutes.Welcome]: undefined;
[OnboardingStackRoutes.AnalyticsConsent]: undefined;
[OnboardingStackRoutes.Biometrics]: undefined;
[OnboardingStackRoutes.TradingLocation]: undefined;
};
export type DeviceOnboardingStackParamList = {
@@ -337,6 +338,7 @@ export type RootStackParamList = {
orderId?: string;
};
[RootStackRoutes.BootloaderMode]: undefined;
[RootStackRoutes.TradingLocationModal]: undefined;
};
export type TradingStackParamList = {

View File

@@ -23,6 +23,7 @@ export enum RootStackRoutes {
BackupFailedModal = 'BackupFailedModal',
TradingWebView = 'TradingWebView',
BootloaderMode = 'BootloaderMode',
TradingLocationModal = 'TradingLocationModal',
}
export enum AppTabsRoutes {
@@ -36,6 +37,7 @@ export enum OnboardingStackRoutes {
Welcome = 'Welcome',
AnalyticsConsent = 'AnalyticsConsent',
Biometrics = 'Biometrics',
TradingLocation = 'TradingLocation',
}
export enum DeviceOnboardingStackRoutes {

View File

@@ -20,7 +20,7 @@ export const ConfirmLocationButton = ({ afterConfirm }: ConfirmLocationButtonPro
};
return (
<Button colorScheme="primary" size="large" onPress={confirmLocation}>
<Button colorScheme="primary" size="medium" onPress={confirmLocation}>
<Translation id="tradingResidence.locationSettings.confirmButton" />
</Button>
);

View File

@@ -1,20 +1,19 @@
import { useDispatch } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { ConfirmLocationButton } from './ConfirmLocationButton';
import { SkipButton } from './SkipButton';
import { tradingResidenceActions } from '../reducers/residenceSlice';
export const OnboardingButtons = () => {
const navigation = useNavigation();
export type OnboardingButtonsProps = {
afterPress: () => void;
};
export const OnboardingButtons = ({ afterPress }: OnboardingButtonsProps) => {
const dispatch = useDispatch();
const handleOnboardingComplete = () => {
dispatch(tradingResidenceActions.setOnboardingVisited());
// todo 22469 navigate to dashboard
navigation.goBack();
afterPress();
};
return (

View File

@@ -6,7 +6,7 @@ export type SkipButtonProps = {
};
export const SkipButton = ({ onPress }: SkipButtonProps) => (
<Button colorScheme="tertiaryElevation0" size="large" onPress={onPress}>
<Button colorScheme="tertiaryElevation0" size="medium" onPress={onPress}>
<Translation id="tradingResidence.locationSettings.skipButton" />
</Button>
);

View File

@@ -20,7 +20,7 @@ export const TradingLocationSettings = ({ context, children }: TradingLocationSe
<Box flex={1} alignItems="center" justifyContent="center">
<GlobeSvg />
</Box>
<VStack paddingVertical="sp32" spacing="sp24">
<VStack paddingTop="sp32" spacing="sp24">
<VStack spacing="sp8">
<Text variant="titleMedium" color="textDefault">
<Translation id="tradingResidence.locationSettings.title" />
@@ -38,7 +38,7 @@ export const TradingLocationSettings = ({ context, children }: TradingLocationSe
</Card>
<TradingAvailability />
</VStack>
<VStack spacing="sp8">{children}</VStack>
<VStack spacing="sp12">{children}</VStack>
</VStack>
</VStack>
</LocationForm>

View File

@@ -10,27 +10,23 @@ import {
selectWasTradingResidenceOnboardingVisited,
} from '../../selectors/residenceSelectors';
import { LocationForm } from '../LocationForm';
import { OnboardingButtons } from '../OnboardingButtons';
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => ({
goBack: () => jest.fn(),
}),
}));
import { OnboardingButtons, OnboardingButtonsProps } from '../OnboardingButtons';
describe('OnboardingButtons', () => {
let store: TestStore;
const renderOnboardingButtons = () =>
renderWithStoreProviderAsync(<OnboardingButtons />, { wrapper: LocationForm, store });
const renderOnboardingButtons = (props: OnboardingButtonsProps) =>
renderWithStoreProviderAsync(<OnboardingButtons {...props} />, {
wrapper: LocationForm,
store,
});
beforeEach(async () => {
store = await initStore();
});
it('should render correctly', async () => {
const { getByText } = await renderOnboardingButtons();
const { getByText } = await renderOnboardingButtons({ afterPress: () => {} });
expect(getByText('Confirm location')).toBeOnTheScreen();
expect(getByText('Not now')).toBeOnTheScreen();
@@ -41,21 +37,25 @@ describe('OnboardingButtons', () => {
});
it('should dispatch setResidenceCountry and setOnboardingVisited on `Confirm location` press', async () => {
const { getByText } = await renderOnboardingButtons();
const afterPressMock = jest.fn();
const { getByText } = await renderOnboardingButtons({ afterPress: afterPressMock });
fireEvent.press(getByText('Confirm location'));
// from expo-localization mock
expect(selectTradingResidenceCountry(store.getState())).toBe('PL');
expect(selectWasTradingResidenceOnboardingVisited(store.getState())).toBe(true);
expect(afterPressMock).toHaveBeenCalledTimes(1);
});
it('should dispatch only setOnboardingVisited on `Not now` press', async () => {
const { getByText } = await renderOnboardingButtons();
const afterPressMock = jest.fn();
const { getByText } = await renderOnboardingButtons({ afterPress: afterPressMock });
fireEvent.press(getByText('Not now'));
expect(selectTradingResidenceCountry(store.getState())).toBeUndefined();
expect(selectWasTradingResidenceOnboardingVisited(store.getState())).toBe(true);
expect(afterPressMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -7,3 +7,5 @@ export * from './components/CountrySheet/CountryOfResidencePicker';
export * from './components/TradingLocationSettings';
export * from './components/ConfirmLocationButton';
export * from './components/OnboardingButtons';
export * from './screens/TradingLocationModalScreen';

View File

@@ -1,12 +1,41 @@
import { Screen } from '@suite-native/navigation';
import { CommonActions } from '@react-navigation/native';
import {
HomeStackRoutes,
RootStackParamList,
RootStackRoutes,
Screen,
StackProps,
} from '@suite-native/navigation';
import { OnboardingButtons } from '../components/OnboardingButtons';
import { TradingLocationSettings } from '../components/TradingLocationSettings';
export const TradingLocationModalScreen = () => (
<Screen>
<TradingLocationSettings context="onboarding">
<OnboardingButtons />
</TradingLocationSettings>
</Screen>
);
export type TradingLocationModalScreenProps = StackProps<
RootStackParamList,
RootStackRoutes.TradingLocationModal
>;
export const TradingLocationModalScreen = ({ navigation }: TradingLocationModalScreenProps) => {
const resetToHome = () => {
navigation.dispatch(
CommonActions.reset({
index: 0,
routes: [
{
name: RootStackRoutes.AppTabs,
params: { screen: HomeStackRoutes.Home },
},
],
}),
);
};
return (
<Screen>
<TradingLocationSettings context="onboarding">
<OnboardingButtons afterPress={resetToHome} />
</TradingLocationSettings>
</Screen>
);
};

View File

@@ -1,12 +0,0 @@
import { Screen, ScreenHeader } from '@suite-native/navigation';
import { OnboardingButtons } from '../components/OnboardingButtons';
import { TradingLocationSettings } from '../components/TradingLocationSettings';
export const TradingLocationOnboardingScreen = () => (
<Screen header={<ScreenHeader />}>
<TradingLocationSettings context="onboarding">
<OnboardingButtons />
</TradingLocationSettings>
</Screen>
);

View File

@@ -1,10 +1,24 @@
import { RouteProp } from '@react-navigation/native';
import { EventType, analytics } from '@suite-native/analytics';
import { TradingStackParamList, TradingStackRoutes } from '@suite-native/navigation';
import {
RootStackRoutes,
TradingStackParamList,
TradingStackRoutes,
} from '@suite-native/navigation';
import { renderWithStoreProviderAsync, userEvent } from '@suite-native/test-utils';
import { TradingLocationModalScreen } from '../TradingLocationModalScreen';
import {
TradingLocationModalScreen,
TradingLocationModalScreenProps,
} from '../TradingLocationModalScreen';
const mockNavigationDispatch = jest.fn();
const mockRoute: TradingLocationModalScreenProps['route'] = {
name: RootStackRoutes.TradingLocationModal,
key: 'TradingLocationModal',
params: undefined,
};
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
@@ -12,11 +26,23 @@ jest.mock('@react-navigation/native', () => ({
({
params: undefined,
}) as RouteProp<TradingStackParamList, TradingStackRoutes.TradingHistory>,
useNavigation: () => ({
dispatch: mockNavigationDispatch,
}),
}));
describe('TradingLocationModalScreen', () => {
const renderTradingLocationModalScreen = () =>
renderWithStoreProviderAsync(<TradingLocationModalScreen />);
renderWithStoreProviderAsync(
<TradingLocationModalScreen
navigation={
{
dispatch: mockNavigationDispatch,
} as unknown as TradingLocationModalScreenProps['navigation']
}
route={mockRoute}
/>,
);
it('should render all components', async () => {
const { getByText, queryByLabelText } = await renderTradingLocationModalScreen();
@@ -44,4 +70,24 @@ describe('TradingLocationModalScreen', () => {
},
});
});
it('should reset navigation on button press', async () => {
const { getByText } = await renderTradingLocationModalScreen();
await userEvent.press(getByText('Not now'));
expect(mockNavigationDispatch).toHaveBeenCalledTimes(1);
expect(mockNavigationDispatch).toHaveBeenCalledWith({
type: 'RESET',
payload: {
index: 0,
routes: [
{
name: RootStackRoutes.AppTabs,
params: { screen: 'Home' },
},
],
},
});
});
});

View File

@@ -14,6 +14,7 @@ import {
selectIsTradingCountrySet,
selectIsTradingEnabledForCountry,
selectIsTradingResidenceCheckEnabled,
selectShouldDisplayTradingResidenceOnboarding,
selectTradingResidenceCountry,
selectWasTradingResidenceOnboardingVisited,
} from '../residenceSelectors';
@@ -124,4 +125,42 @@ describe('residenceSelectors', () => {
expect(selectIsTradingCountrySet(state)).toBe(true);
});
});
describe('selectShouldDisplayTradingResidenceOnboarding', () => {
it('should return false when residence check FF is disabled', () => {
const state = {
...getRootResidenceState(tradingResidenceInitialState),
...getRootFFState(false),
};
expect(selectShouldDisplayTradingResidenceOnboarding(state)).toBe(false);
});
it('should return false when onboarding was already visited (FF enabled)', () => {
const state = {
...getRootResidenceState(visitedState),
...getRootFFState(true),
};
expect(selectShouldDisplayTradingResidenceOnboarding(state)).toBe(false);
});
it('should return false when country is already set (FF enabled)', () => {
const state = {
...getRootResidenceState({ country: 'US' }),
...getRootFFState(true),
};
expect(selectShouldDisplayTradingResidenceOnboarding(state)).toBe(false);
});
it('should return true when FF enabled, onboarding not visited and country not set', () => {
const state = {
...getRootResidenceState(tradingResidenceInitialState),
...getRootFFState(true),
};
expect(selectShouldDisplayTradingResidenceOnboarding(state)).toBe(true);
});
});
});

View File

@@ -34,3 +34,13 @@ export const selectIsTradingEnabledForCountry = (
export const selectIsTradingCountrySet = (state: TradingResidenceRootState) =>
selectTradingResidenceCountry(state) !== undefined;
export const selectShouldDisplayTradingResidenceOnboarding = (
state: TradingResidenceRootState & FeatureFlagsRootState,
) => {
const isResidenceCheckEnabled = selectIsTradingResidenceCheckEnabled(state);
const wasOnboardingVisited = selectWasTradingResidenceOnboardingVisited(state);
const isCountrySet = selectIsTradingCountrySet(state);
return isResidenceCheckEnabled && !wasOnboardingVisited && !isCountrySet;
};

View File

@@ -11198,6 +11198,7 @@ __metadata:
"@suite-native/test-utils": "workspace:*"
"@suite-native/theme": "workspace:*"
"@suite-native/toasts": "workspace:*"
"@suite-native/trading-residence": "workspace:*"
"@suite-native/transactions": "workspace:*"
"@trezor/blockchain-link-types": "workspace:*"
"@trezor/bundler-security": "workspace:*"
@@ -11623,6 +11624,8 @@ __metadata:
dependencies:
"@reduxjs/toolkit": "npm:2.9.1"
"@suite-native/config": "workspace:*"
"@trezor/env-utils": "workspace:*"
react-native: "npm:0.81.4"
react-redux: "npm:9.2.0"
languageName: unknown
linkType: soft
@@ -12337,6 +12340,8 @@ __metadata:
"@suite-native/link": "workspace:*"
"@suite-native/navigation": "workspace:*"
"@suite-native/settings": "workspace:*"
"@suite-native/test-utils": "workspace:*"
"@suite-native/trading-residence": "workspace:*"
"@trezor/env-utils": "workspace:*"
"@trezor/styles": "workspace:*"
"@trezor/theme": "workspace:*"