ui: Keypad currency toggle modal

This commit is contained in:
Evan Kaloudis
2026-02-12 16:17:31 -05:00
parent 3ce5943c82
commit d6fd153ca7
5 changed files with 511 additions and 8 deletions
+282
View File
@@ -0,0 +1,282 @@
import * as React from 'react';
import { FlatList, View, Text } from 'react-native';
import { Icon, ListItem, SearchBar } from '@rneui/themed';
import { inject, observer } from 'mobx-react';
import SettingsStore, {
CURRENCY_KEYS,
DEFAULT_FIAT,
DEFAULT_FIAT_RATES_SOURCE
} from '../stores/SettingsStore';
import UnitsStore from '../stores/UnitsStore';
import FiatStore from '../stores/FiatStore';
import { localeString } from '../utils/LocaleUtils';
import { themeColor } from '../utils/ThemeUtils';
import { numberWithCommas, numberWithDecimals } from '../utils/UnitsUtils';
import BitcoinIcon from '../assets/images/SVG/bitcoin-icon.svg';
interface CurrencyListProps {
SettingsStore?: SettingsStore;
UnitsStore?: UnitsStore;
FiatStore?: FiatStore;
onSelect: (currency: string, type: 'unit' | 'fiat') => void;
}
interface CurrencyListState {
search: string;
currencies: typeof CURRENCY_KEYS;
fiatRatesSource: string;
}
const BITCOIN_UNITS = [
{ key: 'Satoshis (sats)', value: 'sats' },
{ key: 'Bitcoin (BTC)', value: 'BTC' }
];
@inject('SettingsStore', 'UnitsStore', 'FiatStore')
@observer
export default class CurrencyList extends React.Component<
CurrencyListProps,
CurrencyListState
> {
constructor(props: CurrencyListProps) {
super(props);
this.state = {
search: '',
currencies: CURRENCY_KEYS,
fiatRatesSource: DEFAULT_FIAT_RATES_SOURCE
};
}
renderSeparator = () => (
<View
style={{
height: 1,
backgroundColor: themeColor('separator')
}}
/>
);
updateSearch = (value: string) => {
const result = CURRENCY_KEYS.filter((item: any) => {
const currencyString = `${item.flag ? item.flag : ''} ${item.key} ${
item.value ? `(${item.value})` : ''
}`;
return currencyString.toLowerCase().includes(value.toLowerCase());
});
this.setState({
search: value,
currencies: result
});
};
handleSelect = async (value: string, type: 'unit' | 'fiat') => {
const { SettingsStore, UnitsStore, onSelect } = this.props;
if (type === 'unit') {
await UnitsStore!.setUnits(value);
} else {
await SettingsStore!.updateSettings({ fiat: value });
await UnitsStore!.setUnits('fiat');
}
onSelect(value, type);
};
renderBitcoinUnits = () => {
const { UnitsStore } = this.props;
const currentUnit = UnitsStore!.units;
return (
<View style={{ marginBottom: 8 }}>
{BITCOIN_UNITS.map((item, index) => (
<React.Fragment key={item.value}>
<ListItem
containerStyle={{
borderBottomWidth: 0,
backgroundColor: 'transparent'
}}
onPress={() =>
this.handleSelect(item.value, 'unit')
}
>
<View style={{ marginRight: 8 }}>
<BitcoinIcon height={20} width={20} />
</View>
<ListItem.Content>
<ListItem.Title
style={{
color:
currentUnit === item.value
? themeColor('highlight')
: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{item.key}
</ListItem.Title>
{item.value === 'sats' && (
<ListItem.Subtitle
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Book',
fontSize: 12
}}
>
100,000,000 sats = 1 BTC
</ListItem.Subtitle>
)}
</ListItem.Content>
{currentUnit === item.value && (
<Icon
name="check"
color={themeColor('highlight')}
/>
)}
</ListItem>
{index < BITCOIN_UNITS.length - 1 &&
this.renderSeparator()}
</React.Fragment>
))}
</View>
);
};
render() {
const { SettingsStore, UnitsStore, FiatStore } = this.props;
const { search, fiatRatesSource } = this.state;
const currentUnit = UnitsStore!.units;
const currentFiat = SettingsStore!.settings.fiat || DEFAULT_FIAT;
const fiatRates = FiatStore?.fiatRates;
const currencies = [...this.state.currencies]
.sort((a, b) => a.key.localeCompare(b.key))
.filter((c) => c.supportedSources?.includes(fiatRatesSource));
return (
<View style={{ flex: 1 }}>
<SearchBar
placeholder={localeString('general.search')}
onChangeText={this.updateSearch}
value={search}
inputStyle={{
color: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
placeholderTextColor={themeColor('secondaryText')}
containerStyle={{
backgroundColor: 'transparent',
borderTopWidth: 0,
borderBottomWidth: 0
}}
inputContainerStyle={{
borderRadius: 15,
backgroundColor: themeColor('secondary')
}}
autoCorrect={false}
/>
{!search && this.renderBitcoinUnits()}
{!search && (
<View
style={{
paddingHorizontal: 16,
paddingVertical: 8,
backgroundColor: themeColor('secondary')
}}
>
<Text
style={{
color: themeColor('secondaryText'),
fontFamily: 'PPNeueMontreal-Medium',
fontSize: 14
}}
>
{localeString('views.Settings.Currency.title')}
</Text>
</View>
)}
<FlatList
data={currencies}
renderItem={({ item }) => (
<ListItem
containerStyle={{
borderBottomWidth: 0,
backgroundColor: 'transparent'
}}
onPress={() =>
this.handleSelect(item.value, 'fiat')
}
>
<ListItem.Content>
<ListItem.Title
style={{
color:
currentUnit === 'fiat' &&
currentFiat === item.value
? themeColor('highlight')
: themeColor('text'),
fontFamily: 'PPNeueMontreal-Book'
}}
>
{`${item.flag ? item.flag : ''} ${
item.key
} (${item.value})`}
</ListItem.Title>
{(() => {
const rateEntry = fiatRates?.find(
(r) => r.code === item.value
);
if (!rateEntry) return null;
const {
symbol,
space,
rtl,
separatorSwap
} = FiatStore!.symbolLookup(item.value);
const formattedRate = separatorSwap
? numberWithDecimals(rateEntry.rate)
: numberWithCommas(rateEntry.rate);
const rateDisplay = rtl
? `${formattedRate}${
space ? ' ' : ''
}${symbol} BTC/${item.value}`
: `${symbol}${
space ? ' ' : ''
}${formattedRate} BTC/${item.value}`;
return (
<ListItem.Subtitle
style={{
color: themeColor(
'secondaryText'
),
fontFamily:
'PPNeueMontreal-Book',
fontSize: 12
}}
>
{rateDisplay}
</ListItem.Subtitle>
);
})()}
</ListItem.Content>
{currentUnit === 'fiat' &&
currentFiat === item.value && (
<Icon
name="check"
color={themeColor('highlight')}
/>
)}
</ListItem>
)}
keyExtractor={(item) => item.value}
ItemSeparatorComponent={this.renderSeparator}
/>
</View>
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import * as React from 'react';
import { View, Dimensions } from 'react-native';
import { StackNavigationProp } from '@react-navigation/stack';
import ModalBox from './ModalBox';
import ToggleButton from './ToggleButton';
import CurrencyList from './CurrencyList';
import { localeString } from '../utils/LocaleUtils';
import { themeColor } from '../utils/ThemeUtils';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
interface CurrencySelectorModalProps {
navigation?: StackNavigationProp<any, any>;
onClose?: () => void;
}
interface CurrencySelectorModalState {
activeTab: 'currencies' | 'converter';
}
export default class CurrencySelectorModal extends React.Component<
CurrencySelectorModalProps,
CurrencySelectorModalState
> {
private modalRef = React.createRef<ModalBox>();
constructor(props: CurrencySelectorModalProps) {
super(props);
this.state = {
activeTab: 'currencies'
};
}
open = () => {
this.modalRef.current?.open();
};
close = () => {
this.modalRef.current?.close();
};
handleClose = () => {
const { onClose } = this.props;
if (onClose) onClose();
this.setState({ activeTab: 'currencies' });
};
handleTabToggle = (key: string) => {
if (key === 'converter') {
// Navigate to the existing CurrencyConverter screen
const { navigation } = this.props;
if (navigation) {
this.close();
navigation.navigate('CurrencyConverter');
}
} else {
this.setState({ activeTab: key as 'currencies' | 'converter' });
}
};
handleCurrencySelect = (_currency: string, _type: 'unit' | 'fiat') => {
this.close();
};
render() {
const { activeTab } = this.state;
const TAB_OPTIONS = [
{
key: 'currencies',
label: localeString('views.Settings.Currency.title')
},
{
key: 'converter',
label: localeString('views.Settings.CurrencyConverter.title')
}
];
return (
<ModalBox
ref={this.modalRef}
style={{
height: SCREEN_HEIGHT * 0.9,
backgroundColor: themeColor('background'),
borderTopLeftRadius: 20,
borderTopRightRadius: 20
}}
swipeToClose={true}
swipeArea={60}
backButtonClose={true}
backdropPressToClose={true}
backdrop={true}
position="bottom"
onClosed={this.handleClose}
coverScreen={true}
>
<View style={{ flex: 1, paddingTop: 8 }}>
<View
style={{
width: 40,
height: 4,
backgroundColor: themeColor('secondaryText'),
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16
}}
/>
<ToggleButton
options={TAB_OPTIONS}
value={activeTab}
onToggle={this.handleTabToggle}
/>
<View style={{ flex: 1, marginTop: 16 }}>
<CurrencyList onSelect={this.handleCurrencySelect} />
</View>
</View>
</ModalBox>
);
}
}
+81 -7
View File
@@ -1,10 +1,11 @@
import * as React from 'react';
import { View, Text } from 'react-native';
import { inject, observer } from 'mobx-react';
import Button from '../components/Button';
import UnitsStore from '../stores/UnitsStore';
import SettingsStore from '../stores/SettingsStore';
import SettingsStore, { CURRENCY_KEYS } from '../stores/SettingsStore';
import { themeColor } from '../utils/ThemeUtils';
@@ -12,21 +13,91 @@ interface UnitToggleProps {
UnitsStore?: UnitsStore;
SettingsStore?: SettingsStore;
onToggle?: () => void;
onOpenModal?: () => void;
}
const getFlagForFiat = (fiatCode: string): string => {
const currency = CURRENCY_KEYS.find((c) => c.value === fiatCode);
return currency?.flag || '';
};
// Split a string of multiple flag emojis into individual flags
const splitFlags = (flags: string): string[] => {
if (!flags) return [];
const flagRegex = /\p{Regional_Indicator}{2}/gu;
const matches = flags.match(flagRegex);
return matches || [flags];
};
@inject('UnitsStore', 'SettingsStore')
@observer
export default class UnitToggle extends React.Component<UnitToggleProps, {}> {
render() {
const { UnitsStore, SettingsStore, onToggle } = this.props;
const { UnitsStore, SettingsStore, onToggle, onOpenModal } = this.props;
const { changeUnits, units } = UnitsStore!;
const { settings } = SettingsStore!;
const { fiat } = settings;
const { fiat, fiatEnabled } = settings;
const getDisplayTitle = (): string | React.ReactElement => {
if (units === 'fiat' && fiat) {
const flag = getFlagForFiat(fiat);
if (flag) {
const flags = splitFlags(flag);
if (flags.length > 1) {
// Multiple flags - stack them vertically
return (
<View
style={{
flexDirection: 'row',
alignItems: 'center'
}}
>
<View
style={{
flexDirection: 'column',
marginRight: 4
}}
>
{flags.slice(0, 3).map((f, idx) => (
<Text
key={idx}
style={{
fontSize: 8,
lineHeight: 10
}}
>
{f}
</Text>
))}
</View>
<Text
style={{ color: themeColor('buttonText') }}
>
{fiat}
</Text>
</View>
);
}
return `${flag} ${fiat}`;
}
return fiat;
}
return units;
};
const handlePress = () => {
if (fiatEnabled && onOpenModal) {
onOpenModal();
} else {
if (onToggle) onToggle();
changeUnits();
}
};
return (
<React.Fragment>
<Button
title={units === 'fiat' ? fiat : units}
title={getDisplayTitle()}
icon={{
name: 'import-export',
size: 25,
@@ -35,9 +106,12 @@ export default class UnitToggle extends React.Component<UnitToggleProps, {}> {
adaptiveWidth
quaternary
noUppercase
onPress={() => {
if (onToggle) onToggle();
changeUnits();
onPress={handlePress}
buttonStyle={{
height: 38,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 0
}}
/>
</React.Fragment>
+5
View File
@@ -29,6 +29,11 @@ export default class UnitsStore {
await Storage.setItem(UNIT_KEY, this.units);
};
public setUnits = async (unit: Units | string) => {
this.units = unit;
await Storage.setItem(UNIT_KEY, this.units);
};
public getNextUnit = () => {
const { settings } = this.settingsStore;
const { fiatEnabled } = settings;
+19 -1
View File
@@ -17,6 +17,7 @@ import EcashMintPicker from '../../components/EcashMintPicker';
import EcashToggle from '../../components/EcashToggle';
import ModalBox from '../../components/ModalBox';
import UnitToggle from '../../components/UnitToggle';
import CurrencySelectorModal from '../../components/CurrencySelectorModal';
import WalletHeader from '../../components/WalletHeader';
import { getSatAmount } from '../../components/AmountInput';
import { Row } from '../../components/layout/Row';
@@ -337,6 +338,15 @@ export default class KeypadPane extends React.PureComponent<
};
private modalBoxRef = React.createRef<ModalBox>();
private currencySelectorModalRef = React.createRef<CurrencySelectorModal>();
handleOpenCurrencyModal = () => {
this.currencySelectorModalRef.current?.open();
};
handleCurrencyModalClose = () => {
this.clearValue();
};
render() {
const {
@@ -503,7 +513,10 @@ export default class KeypadPane extends React.PureComponent<
)}
</>
)}
<UnitToggle onToggle={this.clearValue} />
<UnitToggle
onToggle={this.clearValue}
onOpenModal={this.handleOpenCurrencyModal}
/>
</Row>
</Animated.View>
</View>
@@ -784,6 +797,11 @@ export default class KeypadPane extends React.PureComponent<
</TouchableOpacity>
</View>
</ModalBox>
<CurrencySelectorModal
ref={this.currencySelectorModalRef}
navigation={navigation}
onClose={this.handleCurrencyModalClose}
/>
</>
);
}