feat(suite-native): Settings: Change Device Name

This commit is contained in:
brantalikp
2025-06-17 11:16:00 +02:00
committed by Bohdan Juříček
parent c956709c65
commit d67742d376
23 changed files with 441 additions and 43 deletions

View File

@@ -1,5 +1,7 @@
// Regular expression to match non-ASCII characters
const nonAsciiPattern = /[^\x20-\x7E]/g;
const ASCII_RANGE = '[^\x20-\x7E]';
const nonAsciiPattern = new RegExp(ASCII_RANGE);
const nonAsciiPatternGlobal = new RegExp(ASCII_RANGE, 'g');
export function isAscii(value?: string): boolean {
if (!value) return true;
@@ -10,5 +12,5 @@ export function isAscii(value?: string): boolean {
export function getNonAsciiChars(value?: string): RegExpMatchArray | null {
if (!value) return null;
return value.match(nonAsciiPattern);
return value.match(nonAsciiPatternGlobal);
}

View File

@@ -5,6 +5,12 @@ const redirectToDeviceAuthenticityScreenButton = element(
);
class DeviceSettingsActions {
async waitForSettingsScreen() {
await waitFor(element(by.id('@screen/DeviceSettings')))
.toBeVisible()
.withTimeout(10000);
}
async waitForPinProtectionScreen() {
await waitFor(element(by.id('@screen/PinProtection')))
.toBeVisible()
@@ -57,6 +63,23 @@ class DeviceSettingsActions {
await scrollUntilVisible(redirectToDeviceAuthenticityScreenButton);
}
async tapChangeDeviceNameButton() {
const changeDeviceNameButton = element(by.id('@device-name/change-button'));
await waitFor(changeDeviceNameButton).toBeVisible().withTimeout(10000);
await changeDeviceNameButton.tap();
}
async submitNewDeviceName(value: string) {
const changeDeviceNameInput = element(by.id('@device-name/input'));
const changeDeviceNameSubmitButton = element(by.id('@device-name/submit-button'));
await waitFor(changeDeviceNameInput).toBeVisible().withTimeout(10000);
await changeDeviceNameInput.tap();
await changeDeviceNameInput.replaceText(value);
await changeDeviceNameSubmitButton.tap();
}
async tapCheckAuthenticityButton() {
const checkDeviceAuthenticityButton = element(by.id('@device-authenticity/check-button'));
await waitFor(checkDeviceAuthenticityButton).toBeVisible().withTimeout(5_000);

View File

@@ -91,4 +91,14 @@ conditionalDescribe(device.getPlatform() === 'android', 'Device settings', () =>
await onDeviceSettings.waitForDeviceAuthenticityScreen();
});
test('Change Device Name', async () => {
await onDeviceSettings.tapChangeDeviceNameButton();
await onDeviceSettings.submitNewDeviceName('new name');
await TrezorUserEnvLink.pressYes();
await onDeviceSettings.waitForSettingsScreen();
expect(element(by.label('new name'))).toBeVisible();
});
});

View File

@@ -41,6 +41,7 @@ export type InputProps = TextInputProps &
leftIcon?: ReactNode;
rightIcon?: ReactNode;
elevation?: SurfaceElevation;
keepPlaceholderOnFocus?: boolean;
},
'label' | 'placeholder'
>;
@@ -244,10 +245,11 @@ export const Input = forwardRef<TextInput, InputProps>(
leftIcon,
rightIcon,
style,
editable,
hasError = false,
hasWarning = false,
elevation = '0',
editable,
keepPlaceholderOnFocus = false,
...props
}: InputProps,
ref,
@@ -274,6 +276,10 @@ export const Input = forwardRef<TextInput, InputProps>(
onBlur?.(event);
};
const shouldShowPlaceholder = keepPlaceholderOnFocus
? S.isEmpty(value)
: !isFocused && S.isEmpty(value);
return (
<>
<Box
@@ -310,7 +316,7 @@ export const Input = forwardRef<TextInput, InputProps>(
{label}
</Animated.Text>
)}
{!isFocused && S.isEmpty(value) && placeholder && (
{shouldShowPlaceholder && placeholder && (
<Animated.View
entering={labelEnteringAnimation}
exiting={labelExitingAnimation}

View File

@@ -21,8 +21,9 @@ const labelStyle = prepareNativeStyle(utils => ({
}));
const hintStyle = prepareNativeStyle(
(_, { error, hint }: Pick<InputWrapperProps, 'error' | 'hint'>) => ({
(utils, { error, hint }: Pick<InputWrapperProps, 'error' | 'hint'>) => ({
marginTop: 0,
marginLeft: utils.spacings.sp12,
extend: {
condition: !!error || !!hint,
style: {

View File

@@ -17,6 +17,7 @@
"react": "19.0.0",
"react-hook-form": "^7.56.3",
"react-native": "0.79.3",
"react-native-reanimated": "^3.18.0",
"yup": "^1.6.1"
}
}

View File

@@ -0,0 +1,40 @@
import { ComponentProps, PropsWithChildren } from 'react';
import Animated, {
SlideInDown,
SlideOutDown,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import { Button } from '@suite-native/atoms';
type FormSubmitButtonProps = PropsWithChildren<{
isVisible: boolean;
}> &
ComponentProps<typeof Button>;
export const FormSubmitButton = ({
isVisible,
onPress,
children,
...restProps
}: FormSubmitButtonProps) => {
const animatedButtonContainerStyle = useAnimatedStyle(
() => ({
height: withTiming(isVisible ? 50 : 0),
}),
[isVisible],
);
return (
<Animated.View style={animatedButtonContainerStyle}>
{isVisible && (
<Animated.View entering={SlideInDown} exiting={SlideOutDown}>
<Button onPress={onPress} {...restProps}>
{children}
</Button>
</Animated.View>
)}
</Animated.View>
);
};

View File

@@ -5,3 +5,4 @@ export * from './types';
export * from './hooks/useForm';
export * from './hooks/useFormContext';
export * from './hooks/useField';
export * from './components/FormSubmitButton';

View File

@@ -549,6 +549,18 @@ export const en = {
checks: 'Checks',
dangerZone: 'Danger Zone',
},
changeDeviceName: {
title: `Rename your Trezor`,
validations: {
noSpecialCharacters: 'Your Trezors name cant contain special characters',
maxLengthInfo: 'The name can be 16 characters long at most',
englishLettersOnly: 'Your Trezors name can only contain english letters',
},
submitButton: 'Confirm',
loadingSuccessScreen: {
title: 'Name changed!',
},
},
pinProtection: {
title: 'PIN protection',
content: 'PIN protects your device against physical attack.',

View File

@@ -25,6 +25,7 @@
"@trezor/connect": "workspace:*",
"@trezor/device-utils": "workspace:*",
"@trezor/styles": "workspace:*",
"@trezor/utils": "workspace:*",
"react": "19.0.0",
"react-native": "0.79.3",
"react-redux": "9.2.0"

View File

@@ -0,0 +1,62 @@
import { useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { selectHasRunningDiscovery } from '@suite-common/wallet-core';
import { HStack, IconButton, Text, VStack } from '@suite-native/atoms';
import { DeviceImage } from '@suite-native/device';
import { useIsMultiline } from '@suite-native/helpers';
import {
DeviceSettingsStackParamList,
DeviceSettingsStackRoutes,
StackNavigationProps,
} from '@suite-native/navigation';
import { DeviceModelInternal } from '@trezor/device-utils';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
type DeviceInfoProps = {
deviceModel: DeviceModelInternal;
deviceName: string;
};
type NavigationProp = StackNavigationProps<
DeviceSettingsStackParamList,
DeviceSettingsStackRoutes.DeviceSettings
>;
const textStyle = prepareNativeStyle(_utils => ({
lineHeight: undefined, // Reset line height to default, without this the text cannot align properly
maxWidth: '90%',
}));
export const DeviceInfo = ({ deviceModel, deviceName }: DeviceInfoProps) => {
const isDiscoveryRunning = useSelector(selectHasRunningDiscovery);
const navigation = useNavigation<NavigationProp>();
const { applyStyle } = useNativeStyles();
const { onTextLayout, isMultiline } = useIsMultiline();
const navigateToDeviceNameStack = () => {
navigation.navigate(DeviceSettingsStackRoutes.DeviceNameStack);
};
const name = isMultiline ? deviceName.replace(' ', '\n') : deviceName;
return (
<VStack marginTop="sp24" spacing="sp24" alignItems="center">
<DeviceImage deviceModel={deviceModel} />
<HStack alignItems="center" spacing="sp12">
<Text style={applyStyle(textStyle)} variant="titleMedium" onLayout={onTextLayout}>
{name}
</Text>
<IconButton
onPress={navigateToDeviceNameStack}
isLoading={isDiscoveryRunning}
testID="@device-name/change-button"
size="extraSmall"
iconName="pencilSimpleLine"
colorScheme="tertiaryElevation0"
/>
</HStack>
</VStack>
);
};

View File

@@ -0,0 +1,23 @@
import { yup } from '@suite-common/validators';
import { useTranslate } from '@suite-native/intl';
import { isAscii } from '@trezor/utils';
const noSpecialCharacter = /^(?!.*[\p{M}\p{Lm}])[\x20-\x7E\p{L}\p{N}'-]+$/u;
export const deviceNameFormValidationSchema = (t: ReturnType<typeof useTranslate>['translate']) =>
yup.object({
deviceName: yup
.string()
.test({
test: (value?: string) => {
if (!value || isAscii(value)) return true;
return noSpecialCharacter.test(value);
},
message: t('moduleDeviceSettings.changeDeviceName.validations.noSpecialCharacters'),
})
.test({
test: isAscii,
message: t('moduleDeviceSettings.changeDeviceName.validations.englishLettersOnly'),
}),
});

View File

@@ -0,0 +1,90 @@
import { useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import { selectSelectedDevice } from '@suite-common/wallet-core';
import { useForm } from '@suite-native/forms';
import { useTranslate } from '@suite-native/intl';
import {
DeviceNameStackParamList,
DeviceNameStackRoutes,
StackNavigationProps,
} from '@suite-native/navigation';
import TrezorConnect from '@trezor/connect';
import { EventType, analytics } from '@trezor/suite-analytics';
import { deviceNameFormValidationSchema } from '../deviceNameFormSchema';
export const MAX_LENGTH = 16;
type NavigationProps = StackNavigationProps<
DeviceNameStackParamList,
DeviceNameStackRoutes.DeviceName
>;
export const useChangeDeviceName = () => {
const { translate } = useTranslate();
const navigation = useNavigation<NavigationProps>();
const device = useSelector(selectSelectedDevice);
const form = useForm({
validation: deviceNameFormValidationSchema(translate),
defaultValues: {
deviceName: device?.label || '',
},
mode: 'onChange',
reValidateMode: 'onChange',
});
const deviceNameError = form.formState.errors.deviceName;
const deviceNameValue = form.watch('deviceName');
const isMaxLengthReached = deviceNameValue.length >= MAX_LENGTH;
const isSubmittable = form.formState.isValid && deviceNameValue.length <= MAX_LENGTH;
const hintMessage =
!deviceNameError &&
isMaxLengthReached &&
translate('moduleDeviceSettings.changeDeviceName.validations.maxLengthInfo');
const onSubmit = form.handleSubmit(async ({ deviceName }) => {
if (!device) return;
const label = deviceName.length === 0 ? device.name : deviceName;
if (deviceName === device.label) {
navigation.goBack();
return;
}
navigation.navigate(DeviceNameStackRoutes.ContinueOnTrezor);
const response = await TrezorConnect.applySettings({
device: {
path: device.path,
},
label,
});
if (!response.success) {
navigation.goBack();
return;
}
navigation.navigate(DeviceNameStackRoutes.DeviceNameLoadingScreen);
analytics.report({
type: EventType.SettingsDeviceChangeLabel,
});
});
return {
form,
deviceNameValue,
deviceNameError,
isSubmittable,
hintMessage,
device,
onSubmit,
isMaxLengthReached,
};
};

View File

@@ -0,0 +1,40 @@
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import {
DeviceNameStackParamList,
DeviceNameStackRoutes,
stackNavigationOptionsConfig,
} from '@suite-native/navigation';
import { useDeviceConnectionGuard } from '../hooks/useDeviceConnectionGuard';
import { ContinueOnTrezorScreen } from '../screens/ContinueOnTrezorScreen';
import { DeviceNameLoadingScreen } from '../screens/DeviceNameLoadingScreen';
import { DeviceNameScreen } from '../screens/DeviceNameScreen';
const DeviceNameStack = createNativeStackNavigator<DeviceNameStackParamList>();
export const DeviceNameStackNavigator = () => {
const { isDeviceConnected } = useDeviceConnectionGuard();
if (!isDeviceConnected) return null;
return (
<DeviceNameStack.Navigator
initialRouteName={DeviceNameStackRoutes.DeviceName}
screenOptions={stackNavigationOptionsConfig}
>
<DeviceNameStack.Screen
name={DeviceNameStackRoutes.DeviceName}
component={DeviceNameScreen}
/>
<DeviceNameStack.Screen
name={DeviceNameStackRoutes.ContinueOnTrezor}
component={ContinueOnTrezorScreen}
/>
<DeviceNameStack.Screen
name={DeviceNameStackRoutes.DeviceNameLoadingScreen}
component={DeviceNameLoadingScreen}
/>
</DeviceNameStack.Navigator>
);
};

View File

@@ -7,6 +7,7 @@ import {
} from '@suite-native/navigation';
import { DeviceAuthenticityStackNavigator } from './DeviceAuthenticityStackNavigator';
import { DeviceNameStackNavigator } from './DeviceNameStackNavigator';
import { DevicePinProtectionStackNavigator } from './DevicePinProtectionStackNavigator';
import { WipeDeviceStackNavigator } from './WipeDeviceStackNavigator';
import { ConfirmFirmwareUpdateScreen } from '../screens/ConfirmFirmwareUpdateScreen';
@@ -59,5 +60,9 @@ export const DeviceSettingsStackNavigator = () => (
name={DeviceSettingsStackRoutes.FirmwareInstallation}
component={FirmwareInstallationScreen}
/>
<DeviceSettingsStack.Screen
name={DeviceSettingsStackRoutes.DeviceNameStack}
component={DeviceNameStackNavigator}
/>
</DeviceSettingsStack.Navigator>
);

View File

@@ -0,0 +1,33 @@
import { useNavigation } from '@react-navigation/native';
import { Translation } from '@suite-native/intl';
import {
DeviceSettingsStackParamList,
DeviceSettingsStackRoutes,
LoadingSuccessScreen,
RootStackParamList,
StackToStackCompositeNavigationProps,
} from '@suite-native/navigation';
type NavigationProps = StackToStackCompositeNavigationProps<
DeviceSettingsStackParamList,
DeviceSettingsStackRoutes,
RootStackParamList
>;
export const DeviceNameLoadingScreen = () => {
const navigation = useNavigation<NavigationProps>();
const handleFinish = () => {
navigation.navigate(DeviceSettingsStackRoutes.DeviceSettings);
};
return (
<LoadingSuccessScreen
onFinish={handleFinish}
title={
<Translation id="moduleDeviceSettings.changeDeviceName.loadingSuccessScreen.title" />
}
/>
);
};

View File

@@ -0,0 +1,51 @@
import { Text, TitleHeader, VStack } from '@suite-native/atoms';
import { Form, FormSubmitButton, TextInputField } from '@suite-native/forms';
import { Translation } from '@suite-native/intl';
import { Screen, ScreenHeader } from '@suite-native/navigation';
import { MAX_LENGTH, useChangeDeviceName } from '../hooks/useChangeDeviceName';
export const DeviceNameScreen = () => {
const { form, device, hintMessage, deviceNameValue, onSubmit, isSubmittable } =
useChangeDeviceName();
return (
<Screen header={<ScreenHeader closeActionType="close" />}>
<Form form={form}>
<VStack marginTop="sp32" spacing="sp32" style={{ marginBottom: 'auto' }}>
<TitleHeader
title={<Translation id="moduleDeviceSettings.changeDeviceName.title" />}
titleVariant="titleMedium"
/>
<VStack>
<TextInputField
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
keepPlaceholderOnFocus
placeholder={device?.name || ''}
name="deviceName"
autoCorrect={false}
keyboardType="ascii-capable"
testID="@device-name/input"
accessibilityLabel="device name input"
hint={hintMessage || ''}
maxLength={MAX_LENGTH}
rightIcon={
<Text variant="body" color="textSubdued">
{`${deviceNameValue.length}/${MAX_LENGTH}`}
</Text>
}
/>
</VStack>
</VStack>
<FormSubmitButton
onPress={onSubmit}
isVisible={isSubmittable}
testID="@device-name/submit-button"
>
<Translation id="moduleDeviceSettings.changeDeviceName.submitButton" />
</FormSubmitButton>
</Form>
</Screen>
);
};

View File

@@ -2,18 +2,19 @@ import { useSelector } from 'react-redux';
import { SUPPORTS_DEVICE_AUTHENTICITY_CHECK } from '@suite-common/suite-constants';
import {
selectDeviceLabel,
selectDeviceModel,
selectDeviceName,
selectIsDeviceConnectedViaBluetooth,
} from '@suite-common/wallet-core';
import { Text, VStack } from '@suite-native/atoms';
import { DeviceImage } from '@suite-native/device';
import { VStack } from '@suite-native/atoms';
import { Translation } from '@suite-native/intl';
import { Screen, ScreenHeader } from '@suite-native/navigation';
import { DeviceAuthenticityCard } from '../components/DeviceAuthenticityCard';
import { DeviceBluetoothCard } from '../components/DeviceBluetoothCard';
import { DeviceFirmwareCard } from '../components/DeviceFirmwareCard';
import { DeviceInfo } from '../components/DeviceInfo';
import { DevicePinProtectionCard } from '../components/DevicePinProtectionCard';
import { DeviceSettingsSection } from '../components/DeviceSettingsSection';
import { WipeDeviceCard } from '../components/WipeDeviceCard';
@@ -24,6 +25,7 @@ export const DeviceSettingsModalScreen = () => {
const deviceModel = useSelector(selectDeviceModel);
const deviceName = useSelector(selectDeviceName);
const deviceLabel = useSelector(selectDeviceLabel);
const isDeviceConnectedViaBluetooth = useSelector(selectIsDeviceConnectedViaBluetooth);
if (!deviceModel || !deviceName) {
@@ -33,10 +35,7 @@ export const DeviceSettingsModalScreen = () => {
return (
<Screen header={<ScreenHeader closeActionType="close" />}>
<VStack spacing="sp40">
<VStack marginTop="sp24" spacing="sp24" alignItems="center">
<DeviceImage deviceModel={deviceModel} />
<Text variant="titleMedium">{deviceName}</Text>
</VStack>
<DeviceInfo deviceName={deviceLabel || deviceName} deviceModel={deviceModel} />
<DeviceSettingsSection
title={<Translation id="moduleDeviceSettings.sectionTitles.general" />}
>

View File

@@ -14,6 +14,7 @@
{ "path": "../navigation" },
{ "path": "../../packages/connect" },
{ "path": "../../packages/device-utils" },
{ "path": "../../packages/styles" }
{ "path": "../../packages/styles" },
{ "path": "../../packages/utils" }
]
}

View File

@@ -1,19 +1,12 @@
import Animated, {
FadeInDown,
FadeOutDown,
SlideInDown,
SlideOutDown,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import Animated, { FadeInDown, FadeOutDown } from 'react-native-reanimated';
import { useDispatch, useSelector } from 'react-redux';
import { useRoute } from '@react-navigation/native';
import { AccountsRootState, selectAccountNetworkSymbol } from '@suite-common/wallet-core';
import { BottomSheet, Button, HStack, InlineAlertBox, Text, VStack } from '@suite-native/atoms';
import { BottomSheet, HStack, InlineAlertBox, Text, VStack } from '@suite-native/atoms';
import { CryptoAmountFormatter, CryptoToFiatAmountFormatter } from '@suite-native/formatters';
import { useFormContext } from '@suite-native/forms';
import { FormSubmitButton, useFormContext } from '@suite-native/forms';
import { Translation } from '@suite-native/intl';
import { SendStackParamList, SendStackRoutes, StackProps } from '@suite-native/navigation';
@@ -58,13 +51,6 @@ export const CustomFeeBottomSheet = ({ isVisible, onClose }: CustomFeeBottomShee
onClose();
});
const animatedButtonContainerStyle = useAnimatedStyle(
() => ({
height: withTiming(isSubmittable && isVisible ? 50 : 0),
}),
[isSubmittable, isVisible],
);
if (!symbol) return null;
return (
@@ -108,19 +94,13 @@ export const CustomFeeBottomSheet = ({ isVisible, onClose }: CustomFeeBottomShee
/>
</Animated.View>
)}
<Animated.View style={animatedButtonContainerStyle}>
{isSubmittable && (
<Animated.View entering={SlideInDown} exiting={SlideOutDown}>
<Button
onPress={handleSetCustomFee}
testID="@send/custom-fee-submit-button"
>
<Translation id="moduleSend.fees.custom.bottomSheet.confirmButton" />
</Button>
</Animated.View>
)}
</Animated.View>
<FormSubmitButton
onPress={handleSetCustomFee}
isVisible={isSubmittable && isVisible}
testID="@send/custom-fee-submit-button"
>
<Translation id="moduleSend.fees.custom.bottomSheet.confirmButton" />
</FormSubmitButton>
</VStack>
</BottomSheet>
);

View File

@@ -20,6 +20,7 @@ import {
AuthorizeDeviceStackRoutes,
DevUtilsStackRoutes,
DeviceAuthenticityStackRoutes,
DeviceNameStackRoutes,
DeviceOnboardingStackRoutes,
DevicePinProtectionStackRoutes,
DeviceSettingsStackRoutes,
@@ -202,6 +203,7 @@ export type DeviceSettingsStackParamList = {
[DeviceSettingsStackRoutes.ConfirmFirmwareUpdate]: undefined;
[DeviceSettingsStackRoutes.FirmwareInstallation]: undefined;
[DeviceSettingsStackRoutes.ContinueOnTrezor]: undefined;
[DeviceSettingsStackRoutes.DeviceNameStack]: undefined;
[DeviceSettingsStackRoutes.WipeDeviceStack]: NavigatorScreenParams<WipeDeviceStackParamList>;
[DeviceSettingsStackRoutes.PinProtection]: undefined;
};
@@ -219,6 +221,12 @@ export type WipeDeviceStackParamList = {
[WipeDeviceStackRoutes.WipeDeviceLoadingScreen]: undefined;
};
export type DeviceNameStackParamList = {
[DeviceNameStackRoutes.DeviceName]: undefined;
[DeviceNameStackRoutes.ContinueOnTrezor]: undefined;
[DeviceNameStackRoutes.DeviceNameLoadingScreen]: undefined;
};
export type DeviceAuthenticityStackParamList = {
[DeviceAuthenticityStackRoutes.AuthenticityCheck]: undefined;
[DeviceAuthenticityStackRoutes.AuthenticitySuccess]: undefined;

View File

@@ -79,6 +79,7 @@ export enum DeviceSettingsStackRoutes {
FirmwareInstallation = 'FirmwareInstallation',
ContinueOnTrezor = 'ContinueOnTrezor',
WipeDeviceStack = 'WipeDeviceStack',
DeviceNameStack = 'DeviceNameStack',
}
export enum DevicePinProtectionStackRoutes {
@@ -99,6 +100,12 @@ export enum WipeDeviceStackRoutes {
WipeDeviceLoadingScreen = 'WipeDeviceLoadingScreen',
}
export enum DeviceNameStackRoutes {
DeviceName = 'DeviceName',
ContinueOnTrezor = 'ContinueOnTrezor',
DeviceNameLoadingScreen = 'DeviceNameLoadingScreen',
}
export enum AuthorizeDeviceStackRoutes {
ConnectAndUnlockDevice = 'ConnectAndUnlockDevice',
ConnectBluetoothDevice = 'ConnectBluetoothDevice',

View File

@@ -10443,6 +10443,7 @@ __metadata:
react: "npm:19.0.0"
react-hook-form: "npm:^7.56.3"
react-native: "npm:0.79.3"
react-native-reanimated: "npm:^3.18.0"
yup: "npm:^1.6.1"
languageName: unknown
linkType: soft
@@ -10844,6 +10845,7 @@ __metadata:
"@trezor/connect": "workspace:*"
"@trezor/device-utils": "workspace:*"
"@trezor/styles": "workspace:*"
"@trezor/utils": "workspace:*"
react: "npm:19.0.0"
react-native: "npm:0.79.3"
react-redux: "npm:9.2.0"