Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions wallets/rn_cli_wallet/src/components/SegmentedControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { useEffect } from 'react';
import { StyleSheet, View, LayoutChangeEvent } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolateColor,
useDerivedValue,
} from 'react-native-reanimated';

import { useTheme } from '@/hooks/useTheme';
import { Spacing, BorderRadius } from '@/utils/ThemeUtil';
import { Button } from '@/components/Button';

interface SegmentedControlProps<T extends string> {
options: readonly T[];
selectedOption: T;
onSelect: (option: T) => void;
}

const SPRING_CONFIG = {
damping: 15,
stiffness: 150,
mass: 0.5,
};

export function SegmentedControl<T extends string>({
options,
selectedOption,
onSelect,
}: SegmentedControlProps<T>) {
const Theme = useTheme();

const selectedIndex = options.indexOf(selectedOption);
const segmentWidth = useSharedValue(0);
const containerWidth = useSharedValue(0);
const translateX = useSharedValue(0);

// Update translateX when selection changes
useEffect(() => {
if (segmentWidth.value > 0) {
translateX.value = withSpring(
selectedIndex * segmentWidth.value,
SPRING_CONFIG,
);
}
}, [selectedIndex, segmentWidth, translateX]);

const handleContainerLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
containerWidth.value = width;
const padding = Spacing['05'] * 2;
segmentWidth.value = (width - padding) / options.length;
translateX.value = selectedIndex * segmentWidth.value;
};

const indicatorStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: translateX.value }],
width: segmentWidth.value,
};
});

return (
<View
style={[
styles.container,
{ backgroundColor: Theme['foreground-primary'] },
]}
onLayout={handleContainerLayout}
>
<Animated.View
style={[
styles.indicator,
{ backgroundColor: Theme['foreground-accent-primary-40'] },
indicatorStyle,
]}
/>
{options.map(option => {
const isActive = option === selectedOption;
return (
<Button
key={option}
onPress={() => onSelect(option)}
style={styles.segment}
>
<AnimatedText
text={option}
isActive={isActive}
activeColor={Theme['text-primary']}
inactiveColor={Theme['text-secondary']}
/>
</Button>
);
})}
</View>
);
}

interface AnimatedTextProps {
text: string;
isActive: boolean;
activeColor: string;
inactiveColor: string;
}

function AnimatedText({
text,
isActive,
activeColor,
inactiveColor,
}: AnimatedTextProps) {
const progress = useDerivedValue(() => {
return withSpring(isActive ? 1 : 0, SPRING_CONFIG);
}, [isActive]);

const animatedStyle = useAnimatedStyle(() => {
const color = interpolateColor(
progress.value,
[0, 1],
[inactiveColor, activeColor],
);
return { color };
});

return (
<Animated.Text style={[styles.text, animatedStyle]}>{text}</Animated.Text>
);
}

const styles = StyleSheet.create({
container: {
flexDirection: 'row',
borderRadius: BorderRadius[3],
padding: Spacing['05'],
width: '100%',
position: 'relative',
},
indicator: {
position: 'absolute',
top: Spacing['05'],
left: Spacing['05'],
bottom: Spacing['05'],
borderRadius: BorderRadius[2],
},
segment: {
flex: 1,
paddingVertical: Spacing[2],
borderRadius: BorderRadius[2],
alignItems: 'center',
zIndex: 1,
},
text: {
fontSize: 13,
fontWeight: '500',
},
});
129 changes: 114 additions & 15 deletions wallets/rn_cli_wallet/src/modals/ImportWalletModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,51 +8,138 @@ import {
import Toast from 'react-native-toast-message';

import { useTheme } from '@/hooks/useTheme';
import LogStore from '@/store/LogStore';
import ModalStore from '@/store/ModalStore';
import SettingsStore from '@/store/SettingsStore';
import WalletStore from '@/store/WalletStore';
import { loadEIP155Wallet } from '@/utils/EIP155WalletUtil';
import { loadTonWallet } from '@/utils/TonWalletUtil';
import { loadTronWallet } from '@/utils/TronWalletUtil';
import { loadSuiWallet } from '@/utils/SuiWalletUtil';
import { Text } from '@/components/Text';
import { ModalCloseButton } from '@/components/ModalCloseButton';
import { SegmentedControl } from '@/components/SegmentedControl';
import { Spacing, BorderRadius, FontFamily } from '@/utils/ThemeUtil';
import { ActionButton } from '@/components/ActionButton';

const CHAIN_OPTIONS = ['EVM', 'TON', 'TRON', 'SUI'] as const;
type ChainOption = (typeof CHAIN_OPTIONS)[number];

const PLACEHOLDER_TEXT: Record<ChainOption, string> = {
EVM: 'Enter mnemonic or private key (0x...)',
TON: 'Enter secret key (128 hex) or seed (64 hex)',
TRON: 'Enter private key (64 hex)',
SUI: 'Enter mnemonic phrase (12-24 words)',
};

const EMPTY_INPUT_ERROR: Record<ChainOption, string> = {
EVM: 'Please enter a mnemonic or private key',
TON: 'Please enter a secret key or seed',
TRON: 'Please enter a private key',
SUI: 'Please enter a mnemonic phrase',
};

export default function ImportWalletModal() {
const Theme = useTheme();
const [selectedChain, setSelectedChain] = useState<ChainOption>('EVM');
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);

const handleChainChange = (chain: ChainOption) => {
setSelectedChain(chain);
setInput(''); // Clear input when switching chains
};

const handleImport = async () => {
if (!input.trim()) {
// Normalize input: trim and collapse multiple whitespace to single space
const sanitizedInput = input.trim().replace(/\s+/g, ' ');

if (!sanitizedInput) {
Toast.show({
type: 'error',
text1: 'Please enter a mnemonic or private key',
text1: EMPTY_INPUT_ERROR[selectedChain],
});
return;
}

setIsLoading(true);
try {
const { address } = loadEIP155Wallet(input);
let address: string;

// Refetch balances with the new address
WalletStore.fetchBalances({
eip155Address: address,
tonAddress: SettingsStore.state.tonAddress,
tronAddress: SettingsStore.state.tronAddress,
});
switch (selectedChain) {
case 'EVM': {
const result = loadEIP155Wallet(sanitizedInput);
address = result.address;
// Refetch balances with the new EVM address
WalletStore.fetchBalances({
eip155Address: address,
tonAddress: SettingsStore.state.tonAddress,
tronAddress: SettingsStore.state.tronAddress,
suiAddress: SettingsStore.state.suiAddress,
});
break;
}
case 'TON': {
const result = await loadTonWallet(sanitizedInput);
address = result.address;
// Refetch balances with the new TON address
WalletStore.fetchBalances({
eip155Address: SettingsStore.state.eip155Address,
tonAddress: address,
tronAddress: SettingsStore.state.tronAddress,
suiAddress: SettingsStore.state.suiAddress,
});
break;
}
case 'TRON': {
const result = await loadTronWallet(sanitizedInput);
address = result.address;
// Refetch balances with the new TRON address
WalletStore.fetchBalances({
eip155Address: SettingsStore.state.eip155Address,
tonAddress: SettingsStore.state.tonAddress,
tronAddress: address,
suiAddress: SettingsStore.state.suiAddress,
});
break;
}
case 'SUI': {
const result = await loadSuiWallet(sanitizedInput);
address = result.address;
// Refetch balances with the new SUI address
WalletStore.fetchBalances({
eip155Address: SettingsStore.state.eip155Address,
tonAddress: SettingsStore.state.tonAddress,
tronAddress: SettingsStore.state.tronAddress,
suiAddress: address,
});
break;
}
default: {
const unsupportedChain = selectedChain satisfies never;
LogStore.error(
`Unsupported chain: ${unsupportedChain}`,
'ImportWalletModal',
'handleImport',
);
Toast.show({
type: 'error',
text1: 'Error',
text2: `Unsupported chain: ${unsupportedChain}`,
});
return;
}
}

Toast.show({
type: 'success',
text1: 'Wallet imported!',
text1: `${selectedChain} wallet imported!`,
text2: `New address: ${address}`,
});
ModalStore.close();
} catch (error: unknown) {
const message =
error instanceof Error
? error.message
: 'Invalid mnemonic or private key';
error instanceof Error ? error.message : 'Invalid input';
Toast.show({
type: 'error',
text1: 'Error',
Expand All @@ -73,9 +160,17 @@ export default function ImportWalletModal() {
</View>

<Text variant="h6-400" color="text-primary" center>
Import EVM Wallet
Import Wallet
</Text>

<View style={styles.segmentContainer}>
<SegmentedControl
options={CHAIN_OPTIONS}
selectedOption={selectedChain}
onSelect={handleChainChange}
/>
</View>

<TextInput
style={[
styles.input,
Expand All @@ -85,7 +180,7 @@ export default function ImportWalletModal() {
borderColor: Theme['foreground-tertiary'],
},
]}
placeholder="Enter mnemonic or private key (0x...)"
placeholder={PLACEHOLDER_TEXT[selectedChain]}
placeholderTextColor={Theme['text-secondary']}
value={input}
onChangeText={setInput}
Expand Down Expand Up @@ -134,6 +229,10 @@ const styles = StyleSheet.create({
width: '100%',
marginBottom: Spacing[4],
},
segmentContainer: {
width: '100%',
marginTop: Spacing[4],
},
input: {
borderWidth: 1,
borderRadius: BorderRadius[3],
Expand Down
Loading