From acda9521614458087fd103239a5cd5da87e1d64a Mon Sep 17 00:00:00 2001 From: claudinethelobster Date: Sun, 15 Feb 2026 13:24:30 -0800 Subject: [PATCH 1/2] Add PocketChange UI components and strings - Create PocketChangeModal component with toggle and slider - Add English localization strings for PocketChange - Modal allows enabling/disabling and configuring pocket amount (0.1-1.3 XMR) - Integrates with Airship modal system Integration TODO: - Add modal trigger to Monero wallet settings/menu - Update SendScene to call wallet.otherMethods.getPocketChangeTargetsForSpend() - Show pocket breakdown in send confirmation - Save config to wallet.walletLocalData.pocketChangeSetting --- src/components/modals/PocketChangeModal.tsx | 103 ++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/components/modals/PocketChangeModal.tsx diff --git a/src/components/modals/PocketChangeModal.tsx b/src/components/modals/PocketChangeModal.tsx new file mode 100644 index 00000000000..6039b581bf0 --- /dev/null +++ b/src/components/modals/PocketChangeModal.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import { View } from 'react-native' +import type { AirshipBridge } from 'react-native-airship' + +import { useHandler } from '../../hooks/useHandler' +import { lstrings } from '../../locales/strings' +import { type Theme, useTheme } from '../services/ThemeContext' +import { SettingsHeaderRow } from '../settings/SettingsHeaderRow' +import { SettingsSliderRow } from '../settings/SettingsSliderRow' +import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' +import { EdgeText } from '../themed/EdgeText' +import { ModalMessage } from '../themed/ModalParts' +import { ThemedModal } from '../themed/ThemedModal' + +const POCKET_AMOUNTS_XMR = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3] + +export interface PocketChangeConfig { + enabled: boolean + amountIndex: number // Index into POCKET_AMOUNTS_XMR +} + +interface Props { + bridge: AirshipBridge + initialConfig: PocketChangeConfig +} + +export const PocketChangeModal: React.FC = props => { + const { bridge, initialConfig } = props + const theme = useTheme() + const styles = getStyles(theme) + const [enabled, setEnabled] = React.useState(initialConfig.enabled) + const [amountIndex, setAmountIndex] = React.useState( + initialConfig.amountIndex + ) + + const handleCancel = useHandler((): void => { + bridge.resolve(undefined) + }) + + return ( + + + {lstrings.pocketchange_description} + + + + {enabled && ( + <> + + + + + + + {POCKET_AMOUNTS_XMR[amountIndex]} XMR{' '} + {lstrings.pocketchange_per_pocket} + + + + {lstrings.pocketchange_explainer} + + )} + + + ) +} + +const getStyles = (theme: Theme): ReturnType => { + return makeStyles({ + container: { + flex: 1, + paddingHorizontal: theme.rem(1) + }, + amountDisplay: { + paddingVertical: theme.rem(0.5), + alignItems: 'center' as const + }, + amountText: { + fontSize: theme.rem(1.25), + color: theme.primaryText, + fontWeight: '500' as const + } + }) +} + +const makeStyles = (styles: any): any => styles From 7dfda361685468e811106c4448a460985afaa372 Mon Sep 17 00:00:00 2001 From: claudinethelobster Date: Wed, 18 Feb 2026 01:05:35 -0800 Subject: [PATCH 2/2] fixup! Add PocketChange UI components and strings fixup! Add PocketChange UI components and strings Co-authored-by: Cursor --- eslint.config.mjs | 3 +- src/actions/WalletListMenuActions.tsx | 26 +++-- src/components/modals/PocketChangeModal.tsx | 110 +++++++++++------- src/components/modals/WalletListMenuModal.tsx | 41 ++++++- src/components/scenes/SendScene2.tsx | 53 ++++++++- src/locales/en_US.ts | 17 ++- src/locales/strings/enUS.json | 12 +- 7 files changed, 200 insertions(+), 62 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index fb0252d2c08..e5b0f29cc00 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -107,7 +107,7 @@ export default [ 'src/actions/TransactionExportActions.tsx', 'src/actions/WalletActions.tsx', 'src/actions/WalletListActions.tsx', - 'src/actions/WalletListMenuActions.tsx', + 'src/app.ts', 'src/components/buttons/ButtonsView.tsx', 'src/components/buttons/EdgeSwitch.tsx', @@ -202,7 +202,6 @@ export default [ 'src/components/modals/SwapVerifyTermsModal.tsx', 'src/components/modals/TextInputModal.tsx', 'src/components/modals/TransferModal.tsx', - 'src/components/modals/WalletListMenuModal.tsx', 'src/components/modals/WalletListSortModal.tsx', 'src/components/modals/WcSmartContractModal.tsx', diff --git a/src/actions/WalletListMenuActions.tsx b/src/actions/WalletListMenuActions.tsx index 5dd611b7315..034386c1888 100644 --- a/src/actions/WalletListMenuActions.tsx +++ b/src/actions/WalletListMenuActions.tsx @@ -31,6 +31,7 @@ import { toggleUserPausedWallet } from './SettingsActions' export type WalletListMenuKey = | 'settings' + | 'pocketChange' | 'rename' | 'delete' | 'resync' @@ -65,6 +66,11 @@ export function walletListMenuAction( }) } } + case 'pocketChange': { + return async () => { + // Handled directly in WalletListMenuModal via Airship + } + } case 'manageTokens': { return async (dispatch, getState) => { navigation.navigate('manageTokens', { @@ -79,7 +85,7 @@ export function walletListMenuAction( const { account } = state.core account .changeWalletStates({ [walletId]: { deleted: true } }) - .catch(error => { + .catch((error: unknown) => { showError(error) }) } @@ -118,8 +124,8 @@ export function walletListMenuAction( try { const fioAddresses = await engine.otherMethods.getFioAddressNames() - fioAddress = fioAddresses.length ? fioAddresses[0] : '' - } catch (e: any) { + fioAddress = fioAddresses.length > 0 ? fioAddresses[0] : '' + } catch (e: unknown) { fioAddress = '' } } @@ -129,7 +135,7 @@ export function walletListMenuAction( let additionalMsg: string | undefined let tokenCurrencyCode: string | undefined if (tokenId == null) { - if (fioAddress) { + if (fioAddress !== '') { additionalMsg = lstrings.fragmet_wallets_delete_fio_extra_message_mobile } else if (Object.keys(wallet.currencyConfig.allTokens).length > 0) { @@ -155,7 +161,7 @@ export function walletListMenuAction( )} ${wallet.type} ${wallet.id}` ) }) - .catch(error => { + .catch((error: unknown) => { showError(error) }) @@ -176,7 +182,7 @@ export function walletListMenuAction( } ${tokenId}` ) }) - .catch(error => { + .catch((error: unknown) => { showError(error) }) } @@ -297,8 +303,8 @@ export function walletListMenuAction( ) // Add a copy button only for development let devButtons = {} - // @ts-expect-error - if (global.__DEV__) + // @ts-expect-error - __DEV__ is a RN global not in TS types + if (global.__DEV__ === true) devButtons = { copy: { label: lstrings.fragment_wallets_copy_seed } } @@ -313,8 +319,8 @@ export function walletListMenuAction( buttons={{ ok: { label: lstrings.string_ok_cap }, ...devButtons }} /> )).then(buttonPressed => { - // @ts-expect-error - if (global.__DEV__ && buttonPressed === 'copy') { + // @ts-expect-error - __DEV__ is a RN global not in TS types + if (global.__DEV__ === true && buttonPressed === 'copy') { Clipboard.setString(privateKey) showToast(lstrings.fragment_wallets_copied_seed) } diff --git a/src/components/modals/PocketChangeModal.tsx b/src/components/modals/PocketChangeModal.tsx index 6039b581bf0..dabf7b2181d 100644 --- a/src/components/modals/PocketChangeModal.tsx +++ b/src/components/modals/PocketChangeModal.tsx @@ -4,19 +4,18 @@ import type { AirshipBridge } from 'react-native-airship' import { useHandler } from '../../hooks/useHandler' import { lstrings } from '../../locales/strings' -import { type Theme, useTheme } from '../services/ThemeContext' +import { EdgeButton } from '../buttons/EdgeButton' +import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { SettingsHeaderRow } from '../settings/SettingsHeaderRow' -import { SettingsSliderRow } from '../settings/SettingsSliderRow' import { SettingsSwitchRow } from '../settings/SettingsSwitchRow' -import { EdgeText } from '../themed/EdgeText' -import { ModalMessage } from '../themed/ModalParts' -import { ThemedModal } from '../themed/ThemedModal' +import { EdgeText, Paragraph } from '../themed/EdgeText' +import { EdgeModal } from './EdgeModal' const POCKET_AMOUNTS_XMR = [0.1, 0.2, 0.3, 0.5, 0.8, 1.3] export interface PocketChangeConfig { enabled: boolean - amountIndex: number // Index into POCKET_AMOUNTS_XMR + amountIndex: number } interface Props { @@ -37,67 +36,90 @@ export const PocketChangeModal: React.FC = props => { bridge.resolve(undefined) }) + const handleSave = useHandler((): void => { + bridge.resolve({ enabled, amountIndex }) + }) + + const handleToggle = useHandler((): void => { + setEnabled(prev => !prev) + }) + + const handleDecrease = useHandler((): void => { + setAmountIndex(prev => Math.max(0, prev - 1)) + }) + + const handleIncrease = useHandler((): void => { + setAmountIndex(prev => Math.min(POCKET_AMOUNTS_XMR.length - 1, prev + 1)) + }) + return ( - - {lstrings.pocketchange_description} + {lstrings.pocketchange_description} - {enabled && ( + {enabled ? ( <> - - - + + {POCKET_AMOUNTS_XMR[amountIndex]} XMR{' '} {lstrings.pocketchange_per_pocket} + = POCKET_AMOUNTS_XMR.length - 1} + /> - {lstrings.pocketchange_explainer} + {lstrings.pocketchange_explainer} - )} + ) : null} + + + + - + ) } -const getStyles = (theme: Theme): ReturnType => { - return makeStyles({ - container: { - flex: 1, - paddingHorizontal: theme.rem(1) - }, - amountDisplay: { - paddingVertical: theme.rem(0.5), - alignItems: 'center' as const - }, - amountText: { - fontSize: theme.rem(1.25), - color: theme.primaryText, - fontWeight: '500' as const - } - }) -} - -const makeStyles = (styles: any): any => styles +const getStyles = cacheStyles((theme: Theme) => ({ + container: { + paddingHorizontal: theme.rem(0.5) + }, + stepperRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + paddingVertical: theme.rem(0.5) + }, + amountText: { + fontSize: theme.rem(1.1), + marginHorizontal: theme.rem(1) + }, + saveButton: { + marginTop: theme.rem(1), + marginBottom: theme.rem(0.5) + } +})) diff --git a/src/components/modals/WalletListMenuModal.tsx b/src/components/modals/WalletListMenuModal.tsx index e755b5b6e74..d3634b67a40 100644 --- a/src/components/modals/WalletListMenuModal.tsx +++ b/src/components/modals/WalletListMenuModal.tsx @@ -26,11 +26,12 @@ import { import { getWalletName } from '../../util/CurrencyWalletHelpers' import { EdgeTouchableOpacity } from '../common/EdgeTouchableOpacity' import { CryptoIcon } from '../icons/CryptoIcon' -import { showError } from '../services/AirshipInstance' +import { Airship, showError } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' import { UnscaledText } from '../text/UnscaledText' import { ModalTitle } from '../themed/ModalParts' import { EdgeModal } from './EdgeModal' +import { type PocketChangeConfig, PocketChangeModal } from './PocketChangeModal' interface Option { value: WalletListMenuKey @@ -53,6 +54,7 @@ const icons: Record = { getSeed: 'key', goToParent: 'upcircleo', manageTokens: 'plus', + pocketChange: 'wallet', rawDelete: 'warning', rename: 'edit', resync: 'sync', @@ -75,6 +77,11 @@ export const WALLET_LIST_MENU: Array<{ label: lstrings.settings_asset_settings, value: 'settings' }, + { + pluginIds: ['monero'], + label: lstrings.pocketchange_menu_item, + value: 'pocketChange' + }, { label: lstrings.string_rename, value: 'rename' @@ -142,7 +149,7 @@ export const WALLET_LIST_MENU: Array<{ } ] -export function WalletListMenuModal(props: Props) { +export function WalletListMenuModal(props: Props): React.ReactElement { const { bridge, tokenId, navigation, walletId } = props const [options, setOptions] = React.useState([]) @@ -161,13 +168,41 @@ export function WalletListMenuModal(props: Props) { const theme = useTheme() const styles = getStyles(theme) - const handleCancel = () => { + const handleCancel = (): void => { props.bridge.resolve() } const optionAction = useHandler(async (option: WalletListMenuKey) => { if (loadingOption != null) return // Prevent multiple actions + if (option === 'pocketChange' && wallet != null) { + setLoadingOption(option) + try { + let initialConfig: PocketChangeConfig = { + enabled: false, + amountIndex: 2 + } + if (wallet.otherMethods?.getPocketChangeSetting != null) { + const saved = await wallet.otherMethods.getPocketChangeSetting() + if (saved != null) initialConfig = saved + } + const result = await Airship.show(b => ( + + )) + if ( + result != null && + wallet.otherMethods?.setPocketChangeSetting != null + ) { + await wallet.otherMethods.setPocketChangeSetting(result) + } + bridge.resolve() + } catch (error) { + setLoadingOption(null) + showError(error) + } + return + } + setLoadingOption(option) try { await dispatch( diff --git a/src/components/scenes/SendScene2.tsx b/src/components/scenes/SendScene2.tsx index c417bd5c84c..4944068fc65 100644 --- a/src/components/scenes/SendScene2.tsx +++ b/src/components/scenes/SendScene2.tsx @@ -260,6 +260,10 @@ const SendComponent = (props: Props): React.ReactElement => { // -1 = no max spend, otherwise equal to the index the spendTarget that requested the max spend. const [maxSpendSetter, setMaxSpendSetter] = useState(-1) + const [pocketChangeTargets, setPocketChangeTargets] = useState< + EdgeSpendTarget[] + >([]) + const countryCode = useSelector(state => state.ui.countryCode) const account = useSelector(state => state.core.account) const exchangeRates = useSelector( @@ -1110,6 +1114,29 @@ const SendComponent = (props: Props): React.ReactElement => { ) } + const renderPocketChange = (): React.ReactElement | null => { + if (pocketChangeTargets.length === 0) return null + + return ( + <> + {pocketChangeTargets.map((target, i) => { + const displayAmount = div( + target.nativeAmount ?? '0', + cryptoDisplayDenomination.multiplier, + DECIMAL_PRECISION + ) + return ( + + ) + })} + + ) + } + const renderScamWarning = (): React.ReactElement | null => { const { publicAddress } = spendInfo.spendTargets[0] @@ -1581,9 +1608,32 @@ const SendComponent = (props: Props): React.ReactElement => { } } + // Expand spend targets with pocket change outputs if supported + let finalSpendInfo = spendInfo + if (coreWallet.otherMethods?.getPocketChangeTargetsForSpend != null) { + try { + const expanded = + await coreWallet.otherMethods.getPocketChangeTargetsForSpend( + spendInfo.spendTargets + ) + const pockets = expanded.filter( + (t: EdgeSpendTarget) => t.otherParams?.isPocketChange === true + ) + setPocketChangeTargets(pockets) + if (expanded.length > spendInfo.spendTargets.length) { + finalSpendInfo = { ...spendInfo, spendTargets: expanded } + } + } catch (e: unknown) { + console.log('Pocket change expansion skipped:', e) + setPocketChangeTargets([]) + } + } else { + setPocketChangeTargets([]) + } + makeSpendCounter.current++ const localMakeSpendCounter = makeSpendCounter.current - const edgeTx = await coreWallet.makeSpend(spendInfo) + const edgeTx = await coreWallet.makeSpend(finalSpendInfo) if (localMakeSpendCounter < makeSpendCounter.current) { // This makeSpend result is out of date. Throw it away since a newer one is in flight. // This is not REALLY needed since useAsyncEffect seems to serialize calls into the effect @@ -1770,6 +1820,7 @@ const SendComponent = (props: Props): React.ReactElement => { {renderFees()} + {renderPocketChange()} {renderMetadataNotes()} {renderMemoOptions()} {renderInfoTiles()} diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index e274ddf1b8f..584c3e18c8c 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2497,7 +2497,22 @@ const strings = { 'Please ensure all details are correct before making the transfer.', // #endregion - unknown_error_message: 'An unknown error occurred.' + unknown_error_message: 'An unknown error occurred.', + + // #region Pocket Change + pocketchange_title: 'Pocket Change', + pocketchange_description: + 'Pocket Change pre-splits your balance into smaller outputs for faster spending. Each "pocket" can be spent independently without waiting for change.', + pocketchange_enable: 'Enable Pocket Change', + pocketchange_amount_header: 'Pocket Size', + pocketchange_amount_label: 'Amount per pocket', + pocketchange_per_pocket: 'per pocket', + pocketchange_explainer: + 'When enabled, received funds will automatically be split into pockets of the selected size.', + pocketchange_menu_item: 'Pocket Change', + pocketchange_line_item: 'Pocket Change', + pocketchange_save: 'Save' + // #endregion } as const export default strings diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index d3ce2839f27..dab0e5695e9 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1947,5 +1947,15 @@ "ramp_account_number_label": "Account Number", "ramp_routing_number_label": "Routing Number", "ramp_bank_routing_warning": "Please ensure all details are correct before making the transfer.", - "unknown_error_message": "An unknown error occurred." + "unknown_error_message": "An unknown error occurred.", + "pocketchange_title": "Pocket Change", + "pocketchange_description": "Pocket Change pre-splits your balance into smaller outputs for faster spending. Each \"pocket\" can be spent independently without waiting for change.", + "pocketchange_enable": "Enable Pocket Change", + "pocketchange_amount_header": "Pocket Size", + "pocketchange_amount_label": "Amount per pocket", + "pocketchange_per_pocket": "per pocket", + "pocketchange_explainer": "When enabled, received funds will automatically be split into pockets of the selected size.", + "pocketchange_menu_item": "Pocket Change", + "pocketchange_line_item": "Pocket Change", + "pocketchange_save": "Save" }