From d0701e601383f58c7be6ba0ec412f33b508cf8c8 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Sat, 8 Nov 2025 19:55:30 -0800 Subject: [PATCH 01/27] Add 'Split by percentage' to the split expense table for new splits --- src/CONST/index.ts | 4 + src/components/PercentageForm.tsx | 4 +- .../SplitListItem.tsx | 21 +++- .../SelectionListWithSections/types.ts | 10 ++ src/languages/de.ts | 1 + src/languages/en.ts | 2 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/pages/iou/SplitExpensePage.tsx | 119 +++++++++++++++++- src/styles/index.ts | 9 ++ src/styles/utils/spacing.ts | 4 + src/styles/variables.ts | 4 + 18 files changed, 181 insertions(+), 5 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 0785b360f025b..3b43e7362dac0 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -2818,6 +2818,10 @@ const CONST = { APPROVE: 'approve', TRACK: 'track', }, + SPLIT_TYPE: { + AMOUNT: 'amount', + PERCENTAGE: 'percentage', + }, AMOUNT_MAX_LENGTH: 8, DISTANCE_REQUEST_AMOUNT_MAX_LENGTH: 14, RECEIPT_STATE: { diff --git a/src/components/PercentageForm.tsx b/src/components/PercentageForm.tsx index aee231de526ce..ca9480f958209 100644 --- a/src/components/PercentageForm.tsx +++ b/src/components/PercentageForm.tsx @@ -4,9 +4,9 @@ import useLocalize from '@hooks/useLocalize'; import {replaceAllDigits, stripCommaFromAmount, stripSpacesFromAmount, validatePercentage} from '@libs/MoneyRequestUtils'; import CONST from '@src/CONST'; import TextInput from './TextInput'; -import type {BaseTextInputRef} from './TextInput/BaseTextInput/types'; +import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types'; -type PercentageFormProps = { +type PercentageFormProps = BaseTextInputProps & { /** Amount supplied by the FormProvider */ value?: string; diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index f5c26ed40955b..9b0be4fba2df7 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -4,6 +4,7 @@ import Icon from '@components/Icon'; import {Folder, Tag} from '@components/Icon/Expensicons'; import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; +import PercentageForm from '@components/PercentageForm'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -41,6 +42,11 @@ function SplitListItem({ splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount)); }; + const onSplitExpensePercentageChange = (value: string) => { + const percentageNumber = Number(value || 0); + splitItem.onSplitExpensePercentageChange?.(splitItem.transactionID, Number.isNaN(percentageNumber) ? 0 : percentageNumber); + }; + const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0); const [prefixCharacterMargin, setPrefixCharacterMargin] = useState(CONST.CHARACTER_WIDTH); @@ -137,7 +143,20 @@ function SplitListItem({ - {!splitItem.isEditable ? ( + {splitItem.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE ? ( + !splitItem.isEditable ? ( + {`${splitItem.percentage ?? 0}%`} + ) : ( + + ) + ) : !splitItem.isEditable ? ( void; + + /** Current mode for the split editor: amount or percentage */ + mode?: ValueOf; + + /** Percentage value to show when in percentage mode (0-100) */ + percentage?: number; + + /** Function for updating percentage */ + onSplitExpensePercentageChange?: (currentItemTransactionID: string, percentage: number) => void; }; type SplitListItemProps = ListItemProps; diff --git a/src/languages/de.ts b/src/languages/de.ts index 6eb6a87f970ae..e82d0e430896e 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1504,6 +1504,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Wählen Sie einen Arbeitsbereich aus', + percent: 'Prozent', }, transactionMerge: { listPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 481ec684d0e11..7dbe5cd766502 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1106,6 +1106,7 @@ const translations = { }, iou: { amount: 'Amount', + percent: 'Percent', taxAmount: 'Tax amount', taxRate: 'Tax rate', approve: ({formattedAmount}: {formattedAmount?: string} = {}) => (formattedAmount ? `Approve ${formattedAmount}` : 'Approve'), @@ -1116,6 +1117,7 @@ const translations = { split: 'Split', splitExpense: 'Split expense', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} from ${merchant}`, + splitByPercentage: 'Split by percentage', addSplit: 'Add split', makeSplitsEven: 'Make splits even', editSplits: 'Edit splits', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4ccfcc3e852a3..630d4ccbf3444 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -785,6 +785,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Importe', + percent: 'Porcentaje', taxAmount: 'Importe del impuesto', taxRate: 'Tasa de impuesto', approve: ({formattedAmount} = {}) => (formattedAmount ? `Aprobar ${formattedAmount}` : 'Aprobar'), diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2b999bb9b6bb5..aa5ff61813aab 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1506,6 +1506,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Choisissez un espace de travail', + percent: 'Pourcentage', }, transactionMerge: { listPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index f2fe5cc4be7b1..7cf74775f1fd2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1500,6 +1500,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: "Scegli un'area di lavoro", + percent: 'Percentuale', }, transactionMerge: { listPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 580ebf82079d1..ad1c9364aa5d9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1500,6 +1500,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'ワークスペースを選択', + percent: 'パーセント', }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ee544d2d573fd..ecdf8e845db7c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1500,6 +1500,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Kies een werkruimte', + percent: 'Procent', }, transactionMerge: { listPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index c2b4c11937d71..62c9eb1c976f6 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1498,6 +1498,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Wybierz przestrzeń roboczą', + percent: 'Procent', }, transactionMerge: { listPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 72ebfa5424925..b5d4eb0f62dcb 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1497,6 +1497,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Escolha um espaço de trabalho', + percent: 'Porcentagem', }, transactionMerge: { listPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index c8d26fd965b4f..6ce67c49532ed 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1478,6 +1478,7 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: '选择一个工作区', + percent: '百分比', }, transactionMerge: { listPage: { diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index c571562917077..6dfd4cf2956de 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -1,7 +1,9 @@ import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, Keyboard, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; +import type {SvgProps} from 'react-native-svg/lib/typescript/ReactNativeSVG'; +import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; @@ -13,12 +15,16 @@ import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; import SelectionList from '@components/SelectionListWithSections'; import type {SectionListDataType, SplitListItemType} from '@components/SelectionListWithSections/types'; +import getBackgroundColor from '@components/TabSelector/getBackground'; +import getOpacity from '@components/TabSelector/getOpacity'; +import TabSelectorItem from '@components/TabSelector/TabSelectorItem'; import useDisplayFocusedInputUnderKeyboard from '@hooks/useDisplayFocusedInputUnderKeyboard'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import { addSplitExpenseField, @@ -45,12 +51,35 @@ import {getReportOrDraftReport, getTransactionDetails, isReportApproved, isSettl import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; import {getChildTransactions, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; type SplitExpensePageProps = PlatformStackScreenProps; +type TabType = { + key: ValueOf; + testID: string; + titleKey: TranslationPaths; + icon: React.FC; +}; + +const tabs: TabType[] = [ + { + key: CONST.IOU.SPLIT_TYPE.AMOUNT, + testID: `split-expense-tab-${CONST.IOU.SPLIT_TYPE.AMOUNT}`, + titleKey: 'iou.amount', + icon: Expensicons.MoneyCircle, + }, + { + key: CONST.IOU.SPLIT_TYPE.PERCENTAGE, + testID: `split-expense-tab-${CONST.IOU.SPLIT_TYPE.PERCENTAGE}`, + titleKey: 'iou.percent', + icon: Expensicons.PlusMinus, + }, +]; + function SplitExpensePage({route}: SplitExpensePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -62,7 +91,15 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [cannotBeEditedModalVisible, setCannotBeEditedModalVisible] = useState(false); const [errorMessage, setErrorMessage] = React.useState(''); + + const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: tabs.length}, (v, i) => i), []); + const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs); + const [selectorWidth, setSelectorWidth] = React.useState(0); + const [selectorX, setSelectorX] = React.useState(0); + const tabSelectorViewRef = useRef(null); + const [isPercentageMode, setIsPercentageMode] = useState(false); const {currentSearchHash} = useSearchContext(); + const theme = useTheme(); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: false}); const transactionReport = getReportOrDraftReport(draftTransaction?.reportID); @@ -98,6 +135,16 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const splitFieldDataFromChildTransactions = useMemo(() => childTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction)), [childTransactions]); const splitFieldDataFromOriginalTransaction = useMemo(() => initSplitExpenseItemData(transaction), [transaction]); + const measure = useCallback(() => { + tabSelectorViewRef.current?.measureInWindow((x, _y, width) => { + setSelectorX(x); + setSelectorWidth(width); + }); + }, [tabSelectorViewRef]); + + // Measure on mount and when layout changes + useEffect(() => measure(), [measure]); + useEffect(() => { const errorString = getLatestErrorMessage(draftTransaction ?? {}); @@ -223,12 +270,27 @@ function SplitExpensePage({route}: SplitExpensePageProps) { [draftTransaction], ); + const onSplitExpensePercentageChange = useCallback( + (currentItemTransactionID: string, percentage: number) => { + if (!transactionDetailsAmount) { + updateSplitExpenseAmountField(draftTransaction, currentItemTransactionID, 0); + return; + } + const clamped = Math.min(100, Math.max(0, Math.round(percentage))); + const totalAbs = Math.abs(transactionDetailsAmount); + const amountInCents = Math.round((totalAbs * clamped) / 100); + updateSplitExpenseAmountField(draftTransaction, currentItemTransactionID, amountInCents); + }, + [draftTransaction, transactionDetailsAmount], + ); + const getTranslatedText = useCallback((item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : (item.text ?? '')), [translate]); const [sections] = useMemo(() => { const dotSeparator: TranslationPathOrText = {text: ` ${CONST.DOT_SEPARATOR} `}; const isTransactionMadeWithCard = isManagedCardTransaction(transaction); const showCashOrCard: TranslationPathOrText = {translationPath: isTransactionMadeWithCard ? 'iou.card' : 'iou.cash'}; + const totalAbs = Math.abs(transactionDetailsAmount); const items: SplitListItemType[] = (draftTransaction?.comment?.splitExpenses ?? []).map((item): SplitListItemType => { const previewHeaderText: TranslationPathOrText[] = [showCashOrCard]; @@ -237,6 +299,8 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const isApproved = isReportApproved({report: currentReport}); const isSettled = isSettledReportUtils(currentReport?.reportID); const isCancelled = currentReport && currentReport?.isCancelledIOU; + const absoluteItemAmount = Math.abs(Number(item.amount ?? 0)); + const percentage = totalAbs > 0 ? Math.round((absoluteItemAmount / totalAbs) * 100) : 0; const date = DateUtils.formatWithUTCTimeZone( item.created, @@ -266,6 +330,9 @@ function SplitExpensePage({route}: SplitExpensePageProps) { transactionID: item?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, currencySymbol, onSplitExpenseAmountChange, + mode: isPercentageMode ? CONST.IOU.SPLIT_TYPE.PERCENTAGE : CONST.IOU.SPLIT_TYPE.AMOUNT, + percentage, + onSplitExpensePercentageChange, isSelected: splitExpenseTransactionID === item.transactionID, keyForList: item?.transactionID, isEditable: (item.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.CLOSED, @@ -283,6 +350,8 @@ function SplitExpensePage({route}: SplitExpensePageProps) { transactionDetailsAmount, currencySymbol, onSplitExpenseAmountChange, + onSplitExpensePercentageChange, + isPercentageMode, splitExpenseTransactionID, translate, getTranslatedText, @@ -343,6 +412,51 @@ function SplitExpensePage({route}: SplitExpensePageProps) { [sections, splitExpenseTransactionID], ); + const headerContent = useMemo(() => { + return ( + + {tabs.map((tab, index) => { + const isActive = tab.key === (isPercentageMode ? CONST.IOU.SPLIT_TYPE.PERCENTAGE : CONST.IOU.SPLIT_TYPE.AMOUNT); + const activeOpacity = getOpacity({routesLength: tabs.length, tabIndex: index, active: true, affectedTabs: affectedAnimatedTabs, position: undefined, isActive}); + const inactiveOpacity = getOpacity({routesLength: tabs.length, tabIndex: index, active: false, affectedTabs: affectedAnimatedTabs, position: undefined, isActive}); + const backgroundColor = getBackgroundColor({routesLength: tabs.length, tabIndex: index, affectedTabs: affectedAnimatedTabs, theme, position: undefined, isActive}); + return ( + { + if (tab.key === CONST.IOU.SPLIT_TYPE.AMOUNT) { + setIsPercentageMode(false); + } else { + setIsPercentageMode(true); + } + }} + shouldShowLabelWhenInactive + backgroundColor={backgroundColor} + inactiveOpacity={inactiveOpacity} + activeOpacity={activeOpacity} + parentWidth={selectorWidth} + parentX={selectorX} + /> + ); + })} + + ); + }, [isPercentageMode, shouldUseNarrowLayout, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, translate]); + + const headerTitle = useMemo(() => { + if (splitExpenseTransactionID) { + return translate('iou.editSplits'); + } + return isPercentageMode ? translate('iou.splitByPercentage') : translate('iou.split'); + }, [splitExpenseTransactionID, isPercentageMode, translate]); + return ( textAlign: 'right', }, + optionRowPercentInputContainer: { + width: variables.splitExpensePercentageMobileWidth, + }, + + optionRowPercentInput: { + width: variables.splitExpensePercentageWidth, + textAlign: 'right', + }, + textInputLabelContainer: { position: 'absolute', left: 8, diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index e0c5ec07c68fe..018074da59c10 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -131,6 +131,10 @@ export default { marginRight: 0, }, + mrHalf: { + marginRight: 2, + }, + mr1: { marginRight: 4, }, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index ad067ed20605d..9d2251bf2568d 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -380,4 +380,8 @@ export default { uberEmployeeInviteButtonWidth: 62, uberEmptyListIconWidth: 190, uberEmptyListIconHeight: 136, + + // Split expense tabs + splitExpensePercentageWidth: 42, + splitExpensePercentageMobileWidth: 62, } as const; From 9930a8b1d7e419dfb274d2b12ac5040e91e3e922 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 10 Nov 2025 13:34:38 -0800 Subject: [PATCH 02/27] added illustration, translations, refactoring and tests --- assets/images/percent.svg | 5 + src/components/Icon/Expensicons.ts | 2 + .../SplitListItem.tsx | 166 ++++++++++-------- .../SelectionListWithSections/types.ts | 2 +- src/languages/de.ts | 1 + src/languages/es.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + src/libs/IOUUtils.ts | 33 ++-- src/libs/actions/IOU.ts | 4 +- src/pages/iou/SplitExpensePage.tsx | 19 +- tests/unit/IOUUtilsTest.ts | 31 ++++ 17 files changed, 173 insertions(+), 98 deletions(-) create mode 100644 assets/images/percent.svg diff --git a/assets/images/percent.svg b/assets/images/percent.svg new file mode 100644 index 0000000000000..1854f92078e70 --- /dev/null +++ b/assets/images/percent.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 1a885a21901c2..83898e707b4d6 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -157,6 +157,7 @@ import Offline from '@assets/images/offline.svg'; import Paperclip from '@assets/images/paperclip.svg'; import Pause from '@assets/images/pause.svg'; import Pencil from '@assets/images/pencil.svg'; +import Percent from '@assets/images/percent.svg'; import Phone from '@assets/images/phone.svg'; import Pin from '@assets/images/pin.svg'; import Plane from '@assets/images/plane.svg'; @@ -355,6 +356,7 @@ export { Paperclip, Pause, Pencil, + Percent, Phone, Pin, Play, diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index 9b0be4fba2df7..f97e9c9878594 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useState} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; import {Folder, Tag} from '@components/Icon/Expensicons'; @@ -38,14 +38,20 @@ function SplitListItem({ const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency); - const onSplitExpenseAmountChange = (amount: string) => { - splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount)); - }; + const onSplitExpenseAmountChange = useCallback( + (amount: string) => { + splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount)); + }, + [splitItem], + ); - const onSplitExpensePercentageChange = (value: string) => { - const percentageNumber = Number(value || 0); - splitItem.onSplitExpensePercentageChange?.(splitItem.transactionID, Number.isNaN(percentageNumber) ? 0 : percentageNumber); - }; + const onSplitExpensePercentageChange = useCallback( + (value: string) => { + const percentageNumber = Number(value || 0); + splitItem.onSplitExpensePercentageChange?.(splitItem.transactionID, Number.isNaN(percentageNumber) ? 0 : percentageNumber); + }, + [splitItem], + ); const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0); @@ -63,6 +69,87 @@ function SplitListItem({ onInputFocus(index); }, [onInputFocus, index]); + const SplitAmountComponent = useMemo(() => { + if (splitItem.isEditable) { + return ( + + ); + } + return ( + + { + if (event.nativeEvent.layout.width === 0 && event.nativeEvent.layout.height === 0) { + return; + } + setPrefixCharacterMargin(event?.nativeEvent?.layout.width); + }} + > + {splitItem.currencySymbol} + + + {convertToDisplayStringWithoutCurrency(splitItem.amount, splitItem.currency)} + + + ); + }, [ + styles, + contentWidth, + inputMarginLeft, + splitItem.isEditable, + splitItem.amount, + splitItem.currency, + splitItem.currencySymbol, + formattedOriginalAmount.length, + onSplitExpenseAmountChange, + focusHandler, + onInputBlur, + ]); + + const SplitPercentageComponent = useMemo(() => { + if (splitItem.isEditable) { + return ( + + ); + } + return {`${splitItem.percentage ?? 0}%`}; + }, [styles, splitItem.isEditable, splitItem.percentage, onSplitExpensePercentageChange, focusHandler, onInputBlur]); + return ( ({ )} - - {splitItem.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE ? ( - !splitItem.isEditable ? ( - {`${splitItem.percentage ?? 0}%`} - ) : ( - - ) - ) : !splitItem.isEditable ? ( - - { - if (event.nativeEvent.layout.width === 0 && event.nativeEvent.layout.height === 0) { - return; - } - setPrefixCharacterMargin(event?.nativeEvent?.layout.width); - }} - > - {splitItem.currencySymbol} - - - {convertToDisplayStringWithoutCurrency(splitItem.amount, splitItem.currency)} - - - ) : ( - - )} - + {splitItem.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE ? SplitPercentageComponent : SplitAmountComponent} {!splitItem.isEditable ? null : ( diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index a73038b45af24..1010cf2361c5e 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -16,7 +16,7 @@ import type { } from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import type {AnimatedStyle} from 'react-native-reanimated'; -import {ValueOf} from 'type-fest'; +import type {ValueOf} from 'type-fest'; import type {SearchRouterItem} from '@components/Search/SearchAutocompleteList'; import type {SearchColumnType, SearchGroupBy, SearchQueryJSON} from '@components/Search/types'; import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; diff --git a/src/languages/de.ts b/src/languages/de.ts index 95905d0e02bff..7abf150388d01 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1505,6 +1505,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: 'Wählen Sie einen Arbeitsbereich aus', percent: 'Prozent', + splitByPercentage: 'Nach Prozentsatz aufteilen', }, transactionMerge: { listPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 1ba7fd34737aa..6300b13597beb 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -796,6 +796,7 @@ const translations: TranslationDeepObject = { split: 'Dividir', splitExpense: 'Dividir gasto', splitExpenseSubtitle: ({amount, merchant}) => `${amount} de ${merchant}`, + splitByPercentage: 'Dividir por porcentaje', addSplit: 'Añadir división', makeSplitsEven: 'Igualar divisiones', editSplits: 'Editar divisiones', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 1e14e9d8206ea..5782ff854b6c5 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1507,6 +1507,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: 'Choisissez un espace de travail', percent: 'Pourcentage', + splitByPercentage: 'Répartir par pourcentage', }, transactionMerge: { listPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index ec69f009cc02a..339c5b457b61c 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1501,6 +1501,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: "Scegli un'area di lavoro", percent: 'Percentuale', + splitByPercentage: 'Ripartisci per percentuale', }, transactionMerge: { listPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 19064a9d3259b..4ba3069356fb3 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1501,6 +1501,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: 'ワークスペースを選択', percent: 'パーセント', + splitByPercentage: '割合で分割', }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5cf7deff2a028..d8a6b7c7b8338 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1501,6 +1501,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: 'Kies een werkruimte', percent: 'Procent', + splitByPercentage: 'Splitsen op percentage', }, transactionMerge: { listPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 991c00bfdcaa8..722180cf574a2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1499,6 +1499,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: 'Wybierz przestrzeń roboczą', percent: 'Procent', + splitByPercentage: 'Podziel procentowo', }, transactionMerge: { listPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 9d0b823a43385..f5fe307ef529d 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1498,6 +1498,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: 'Escolha um espaço de trabalho', percent: 'Porcentagem', + splitByPercentage: 'Dividir por porcentagem', }, transactionMerge: { listPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index afe145e34f6dc..0d791c6fdffdd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1479,6 +1479,7 @@ const translations: TranslationDeepObject = { }, chooseWorkspace: '选择一个工作区', percent: '百分比', + splitByPercentage: '按百分比拆分', }, transactionMerge: { listPage: { diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index cef14183d4eee..6a21107d9a245 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -59,9 +59,9 @@ function navigateToParticipantPage(iouType: ValueOf, tran * @param total - IOU total amount in backend format (cents, no matter the currency) * @param currency - Used to know how many decimal places are valid when splitting the total * @param isDefaultUser - Whether we are calculating the amount for the remainder holder - * @param roundingMode - 'round' (default, legacy behavior) or 'floorToLast' to floor all and put full remainder on the default user + * @param useFloorToLastRounding - `false` (default, legacy behavior) or `true` to floor all and put full remainder on the default user */ -function calculateAmount(numberOfSplits: number, total: number, currency: string, isDefaultUser = false, roundingMode: 'round' | 'floorToLast' = 'round'): number { +function calculateAmount(numberOfSplits: number, total: number, currency: string, isDefaultUser = false, useFloorToLastRounding = false): number { // Since the backend can maximum store 2 decimal places, any currency with more than 2 decimals // has to be capped to 2 decimal places const currencyUnit = Math.min(100, getCurrencyUnit(currency)); @@ -69,18 +69,12 @@ function calculateAmount(numberOfSplits: number, total: number, currency: string const totalParticipants = numberOfSplits + 1; // New optional mode - if (roundingMode === 'floorToLast') { - let baseShareSubunit: number; - let remainderSubunit: number; + if (useFloorToLastRounding) { // For positive totals, floor for everyone and add the full remainder to the default user - if (totalInCurrencySubunit >= 0) { - baseShareSubunit = Math.floor(totalInCurrencySubunit / totalParticipants); - remainderSubunit = totalInCurrencySubunit - baseShareSubunit * totalParticipants; - } else { - // For negative totals, use ceil to move toward zero instead of further down - baseShareSubunit = Math.ceil(totalInCurrencySubunit / totalParticipants); - remainderSubunit = totalInCurrencySubunit - baseShareSubunit * totalParticipants; - } + // For negative totals, do the inverse of above and round up using Math.ceil to calculate the base share + const baseShareSubunit = totalInCurrencySubunit >= 0 ? Math.floor(totalInCurrencySubunit / totalParticipants) : Math.ceil(totalInCurrencySubunit / totalParticipants); + const remainderSubunit = totalInCurrencySubunit - baseShareSubunit * totalParticipants; + const subunitAmount = baseShareSubunit + (isDefaultUser ? remainderSubunit : 0); return Math.round((subunitAmount * 100) / currencyUnit); } @@ -96,6 +90,18 @@ function calculateAmount(numberOfSplits: number, total: number, currency: string return Math.round((finalAmount * 100) / currencyUnit); } +/** + * Calculate a split amount in backend cents from a percentage of the original amount. + * - Clamps percentage to [0, 100] + * - Rounds percentage to whole numbers (per product spec) + * - Uses absolute value of the total amount (cents) + */ +function calculateSplitAmountFromPercentage(totalInCents: number, percentage: number): number { + const totalAbs = Math.abs(totalInCents); + const clamped = Math.min(100, Math.max(0, Math.round(percentage))); + return Math.round((totalAbs * clamped) / 100); +} + /** * The owner of the IOU report is the account who is owed money and the manager is the one who owes money! * In case the owner/manager swap, we need to update the owner of the IOU report and the report total, since it is always positive. @@ -247,6 +253,7 @@ function formatCurrentUserToAttendee(currentUser?: PersonalDetails, reportID?: s export { calculateAmount, + calculateSplitAmountFromPercentage, insertTagIntoTransactionTagsString, isIOUReportPendingCurrencyConversion, isMovingTransactionFromTrackExpense, diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index 75db1317e2fcc..701dd5f378bba 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -13627,9 +13627,11 @@ function evenlyDistributeSplitExpenseAmounts(draftTransaction: OnyxEntry ({ ...splitExpense, - amount: calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, 'floorToLast'), + amount: calculateIOUAmount(splitCount - 1, total, currency, index === lastIndex, true), })); Onyx.merge(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${originalTransactionID}`, { diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index d27815b5af674..2dcee7ecdfa6a 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -1,5 +1,5 @@ import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, Keyboard, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; import type {SvgProps} from 'react-native-svg/lib/typescript/ReactNativeSVG'; @@ -42,6 +42,7 @@ import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; +import {calculateSplitAmountFromPercentage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SplitExpenseParamList} from '@libs/Navigation/types'; @@ -76,7 +77,7 @@ const tabs: TabType[] = [ key: CONST.IOU.SPLIT_TYPE.PERCENTAGE, testID: `split-expense-tab-${CONST.IOU.SPLIT_TYPE.PERCENTAGE}`, titleKey: 'iou.percent', - icon: Expensicons.PlusMinus, + icon: Expensicons.Percent, }, ]; @@ -92,8 +93,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [errorMessage, setErrorMessage] = React.useState(''); - const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: tabs.length}, (v, i) => i), []); - const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs); + const affectedAnimatedTabs = useMemo(() => Array.from({length: tabs.length}, (v, i) => i), []); const [selectorWidth, setSelectorWidth] = React.useState(0); const [selectorX, setSelectorX] = React.useState(0); const tabSelectorViewRef = useRef(null); @@ -272,18 +272,11 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const onSplitExpensePercentageChange = useCallback( (currentItemTransactionID: string, percentage: number) => { - if (!transactionDetailsAmount) { - updateSplitExpenseAmountField(draftTransaction, currentItemTransactionID, 0); - return; - } - const clamped = Math.min(100, Math.max(0, Math.round(percentage))); - const totalAbs = Math.abs(transactionDetailsAmount); - const amountInCents = Math.round((totalAbs * clamped) / 100); + const amountInCents = calculateSplitAmountFromPercentage(transactionDetailsAmount, percentage); updateSplitExpenseAmountField(draftTransaction, currentItemTransactionID, amountInCents); }, [draftTransaction, transactionDetailsAmount], ); - const getTranslatedText = useCallback((item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : (item.text ?? '')), [translate]); const [sections] = useMemo(() => { @@ -448,7 +441,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { })} ); - }, [isPercentageMode, shouldUseNarrowLayout, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, translate]); + }, [isPercentageMode, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, translate]); const headerTitle = useMemo(() => { if (splitExpenseTransactionID) { diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 21cfdda5a8a6d..cc7cbd556ff10 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -153,6 +153,37 @@ describe('IOUUtils', () => { expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 100, 'BHD', true)).toBe(34); expect(IOUUtils.calculateAmount(participantsAccountIDs.length, 100, 'BHD')).toBe(33); }); + + describe('calculateAmount - floorToLast rounding', () => { + beforeAll(() => initCurrencyList()); + + test('Positive total: remainder added entirely to default user', () => { + // $10.00 among 3 -> base 3.33, remainder 0.01 -> default gets 3.34 + const numberOfSplits = 2; // total participants = 3 + expect(IOUUtils.calculateAmount(numberOfSplits, 1000, 'USD', true, true)).toBe(334); + expect(IOUUtils.calculateAmount(numberOfSplits, 1000, 'USD', false, true)).toBe(333); + }); + + test('Negative total: use ceil to move toward zero and remainder applied to default user', () => { + // -$10.00 among 3 -> base -3.33 (ceil to -3333 subunits), remainder -0.01 -> default -3.34 + const numberOfSplits = 2; + expect(IOUUtils.calculateAmount(numberOfSplits, -1000, 'USD', true, true)).toBe(-334); + expect(IOUUtils.calculateAmount(numberOfSplits, -1000, 'USD', false, true)).toBe(-333); + }); + }); + }); + + describe('calculateSplitAmountFromPercentage', () => { + test('Basic percentage calculation and rounding', () => { + expect(IOUUtils.calculateSplitAmountFromPercentage(20000, 25)).toBe(5000); + expect(IOUUtils.calculateSplitAmountFromPercentage(199, 50)).toBe(100); // rounds + }); + + test('Clamps percentage between 0 and 100 and uses absolute total', () => { + expect(IOUUtils.calculateSplitAmountFromPercentage(20000, -10)).toBe(0); + expect(IOUUtils.calculateSplitAmountFromPercentage(20000, 150)).toBe(20000); + expect(IOUUtils.calculateSplitAmountFromPercentage(-20000, 25)).toBe(5000); + }); }); describe('insertTagIntoTransactionTagsString', () => { From c8886b65be0d0b7fb3dc007e877a896917cbb322 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 10 Nov 2025 13:37:38 -0800 Subject: [PATCH 03/27] svg compression --- assets/images/percent.svg | 6 +- .../broken-humpty-dumpty.svg | 196 +----------------- 2 files changed, 2 insertions(+), 200 deletions(-) diff --git a/assets/images/percent.svg b/assets/images/percent.svg index 1854f92078e70..651128b63542a 100644 --- a/assets/images/percent.svg +++ b/assets/images/percent.svg @@ -1,5 +1 @@ - - - - - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/product-illustrations/broken-humpty-dumpty.svg b/assets/images/product-illustrations/broken-humpty-dumpty.svg index d059870f5f4de..75459db01badc 100644 --- a/assets/images/product-illustrations/broken-humpty-dumpty.svg +++ b/assets/images/product-illustrations/broken-humpty-dumpty.svg @@ -1,195 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file From ec4741a8a96d9a50d9a91cb58c3ff12c2f8511ff Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 10 Nov 2025 15:32:09 -0800 Subject: [PATCH 04/27] UI adjustments --- .../SelectionListWithSections/SplitListItem.tsx | 1 + src/pages/iou/SplitExpensePage.tsx | 11 ++++++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index f97e9c9878594..0f2e3fabf01ec 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -140,6 +140,7 @@ function SplitListItem({ childTransactions.length === 0, [childTransactions]); + const listFooterContent = useMemo(() => { - const shouldShowMakeSplitsEven = childTransactions.length === 0; return ( ); - }, [onAddSplitExpense, onMakeSplitsEven, translate, childTransactions, shouldUseNarrowLayout, styles.w100, styles.ph4, styles.flexColumn, styles.mt1, styles.mb3]); + }, [onAddSplitExpense, onMakeSplitsEven, translate, shouldShowMakeSplitsEven, shouldUseNarrowLayout, styles.w100, styles.ph4, styles.flexColumn, styles.mt1, styles.mb3]); const footerContent = useMemo(() => { const shouldShowWarningMessage = sumOfSplitExpenses < transactionDetailsAmount; @@ -406,6 +407,10 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ); const headerContent = useMemo(() => { + // Only show split tab selector if we are creating a split (not editing existing splits) + if (!shouldShowMakeSplitsEven) { + return; + } return ( ); - }, [isPercentageMode, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, translate]); + }, [isPercentageMode, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, shouldShowMakeSplitsEven, translate]); const headerTitle = useMemo(() => { if (splitExpenseTransactionID) { From 2a42e531b8729c38f12aba98784a3595d78e635a Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 10 Nov 2025 17:55:17 -0800 Subject: [PATCH 05/27] fixed ios native style issue --- .../SplitListItem.tsx | 26 +++++++++---------- src/styles/utils/index.ts | 2 ++ .../index.native.ts | 5 ++++ .../utils/splitPercentageInputStyles/index.ts | 5 ++++ .../utils/splitPercentageInputStyles/types.ts | 6 +++++ 5 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 src/styles/utils/splitPercentageInputStyles/index.native.ts create mode 100644 src/styles/utils/splitPercentageInputStyles/index.ts create mode 100644 src/styles/utils/splitPercentageInputStyles/types.ts diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index 0f2e3fabf01ec..ebc40d3bba658 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -135,20 +135,20 @@ function SplitListItem({ ]); const SplitPercentageComponent = useMemo(() => { - if (splitItem.isEditable) { - return ( - - ); + if (!splitItem.isEditable) { + return {`${splitItem.percentage ?? 0}%`}; } - return {`${splitItem.percentage ?? 0}%`}; + return ( + + ); }, [styles, splitItem.isEditable, splitItem.percentage, onSplitExpensePercentageChange, focusHandler, onInputBlur]); return ( diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 31f4e4341dd74..86b3166800805 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -37,6 +37,7 @@ import {compactContentContainerStyles} from './optionRowStyles'; import positioning from './positioning'; import searchHeaderDefaultOffset from './searchHeaderDefaultOffset'; import getSearchPageNarrowHeaderStyles from './searchPageNarrowHeaderStyles'; +import splitPercentageInputStyles from './splitPercentageInputStyles'; import type { AllStyles, AvatarSize, @@ -1333,6 +1334,7 @@ const staticStyleUtils = { getNavigationModalCardStyle, getCardStyles, getSearchPageNarrowHeaderStyles, + splitPercentageInputStyles, getOpacityStyle, getMultiGestureCanvasContainerStyle, getIconWidthAndHeightStyle, diff --git a/src/styles/utils/splitPercentageInputStyles/index.native.ts b/src/styles/utils/splitPercentageInputStyles/index.native.ts new file mode 100644 index 0000000000000..8cea18f7a5d38 --- /dev/null +++ b/src/styles/utils/splitPercentageInputStyles/index.native.ts @@ -0,0 +1,5 @@ +import type SplitPercentageInputStyles from './types'; + +const splitPercentageInputStyles: SplitPercentageInputStyles = (styles) => [styles.flexRow, styles.alignItemsCenter, styles.alignSelfStart]; + +export default splitPercentageInputStyles; diff --git a/src/styles/utils/splitPercentageInputStyles/index.ts b/src/styles/utils/splitPercentageInputStyles/index.ts new file mode 100644 index 0000000000000..4b506b5c82cdf --- /dev/null +++ b/src/styles/utils/splitPercentageInputStyles/index.ts @@ -0,0 +1,5 @@ +import type SplitPercentageInputStyles from './types'; + +const splitPercentageInputStyles: SplitPercentageInputStyles = (styles) => [styles.wFitContent]; + +export default splitPercentageInputStyles; diff --git a/src/styles/utils/splitPercentageInputStyles/types.ts b/src/styles/utils/splitPercentageInputStyles/types.ts new file mode 100644 index 0000000000000..30e80a69d80fc --- /dev/null +++ b/src/styles/utils/splitPercentageInputStyles/types.ts @@ -0,0 +1,6 @@ +import type {ViewStyle} from 'react-native'; +import type {ThemeStyles} from '@styles/index'; + +type SplitPercentageInputStyles = (styles: ThemeStyles) => ViewStyle[]; + +export default SplitPercentageInputStyles; From ae35348a315077458faa944d3badd3246876cada Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 11 Nov 2025 12:55:38 -0800 Subject: [PATCH 06/27] submodule sync --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index a863637e50624..4827dbf59ed88 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit a863637e50624574d424a91e215365bf14fccf68 +Subproject commit 4827dbf59ed888af7428918d93964887029016e5 From 543a93056622865e2ce55ed133c91dc7be801625 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 11 Nov 2025 13:10:22 -0800 Subject: [PATCH 07/27] perf-6 improvements - ready for review --- src/components/SelectionListWithSections/SplitListItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index ebc40d3bba658..9b277ac14f635 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -42,7 +42,7 @@ function SplitListItem({ (amount: string) => { splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount)); }, - [splitItem], + [splitItem.transactionID, splitItem.onSplitExpenseAmountChange], ); const onSplitExpensePercentageChange = useCallback( @@ -50,7 +50,7 @@ function SplitListItem({ const percentageNumber = Number(value || 0); splitItem.onSplitExpensePercentageChange?.(splitItem.transactionID, Number.isNaN(percentageNumber) ? 0 : percentageNumber); }, - [splitItem], + [splitItem.transactionID, splitItem.onSplitExpensePercentageChange], ); const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0); From 1adff7a186e104cdab2f7315742b0552db4f66e3 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 14 Nov 2025 17:50:02 -0800 Subject: [PATCH 08/27] resolved review comments --- .../SplitListItem.tsx | 40 +++++++++---------- src/languages/de.ts | 4 +- src/languages/fr.ts | 4 +- src/languages/it.ts | 4 +- src/languages/ja.ts | 4 +- src/languages/nl.ts | 4 +- src/languages/pl.ts | 4 +- src/languages/pt-BR.ts | 4 +- src/languages/zh-hans.ts | 4 +- src/libs/IOUUtils.ts | 36 +++++++++++++++++ src/pages/iou/SplitExpensePage.tsx | 12 +++--- src/styles/index.ts | 15 +++++++ src/styles/utils/index.ts | 2 + .../splitAmountInputStyles/index.native.ts | 5 +++ .../utils/splitAmountInputStyles/index.ts | 4 ++ .../utils/splitAmountInputStyles/types.ts | 6 +++ src/styles/variables.ts | 2 + tests/unit/IOUUtilsTest.ts | 26 ++++++++++++ 18 files changed, 139 insertions(+), 41 deletions(-) create mode 100644 src/styles/utils/splitAmountInputStyles/index.native.ts create mode 100644 src/styles/utils/splitAmountInputStyles/index.ts create mode 100644 src/styles/utils/splitAmountInputStyles/types.ts diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index 9b277ac14f635..1673cc5a72218 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -6,6 +6,7 @@ import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; import PercentageForm from '@components/PercentageForm'; import Text from '@components/Text'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -33,6 +34,7 @@ function SplitListItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + const {isSmallScreenWidth} = useResponsiveLayout(); const splitItem = item as unknown as SplitListItemType; @@ -56,7 +58,6 @@ function SplitListItem({ const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0); const [prefixCharacterMargin, setPrefixCharacterMargin] = useState(CONST.CHARACTER_WIDTH); - const inputMarginLeft = prefixCharacterMargin + styles.pl1.paddingLeft; const contentWidth = (formattedOriginalAmount.length + 1) * CONST.CHARACTER_WIDTH; const focusHandler = useCallback(() => { if (!onInputFocus) { @@ -84,13 +85,12 @@ function SplitListItem({ submitBehavior="blurAndSubmit" formatAmountOnBlur onAmountChange={onSplitExpenseAmountChange} - prefixContainerStyle={[styles.pv0, styles.h100]} + prefixContainerStyle={[styles.pl1, styles.pv0, styles.h100]} prefixStyle={styles.lineHeightUndefined} - inputStyle={[styles.optionRowAmountInput, styles.lineHeightUndefined]} - containerStyle={[styles.textInputContainer, styles.pl2, styles.pr1]} + inputStyle={styles.optionRowAmountInputContainer} + containerStyle={[StyleUtils.splitAmountInputStyles(styles, isSmallScreenWidth), styles.textInputContainer]} touchableInputWrapperStyle={[styles.ml3]} maxLength={formattedOriginalAmount.length + 1} - contentWidth={contentWidth} shouldApplyPaddingToContainer shouldUseDefaultLineHeightForPrefix={false} shouldWrapInputInContainer={false} @@ -113,7 +113,7 @@ function SplitListItem({ {splitItem.currencySymbol} {convertToDisplayStringWithoutCurrency(splitItem.amount, splitItem.currency)} @@ -123,7 +123,7 @@ function SplitListItem({ }, [ styles, contentWidth, - inputMarginLeft, + prefixCharacterMargin, splitItem.isEditable, splitItem.amount, splitItem.currency, @@ -135,20 +135,20 @@ function SplitListItem({ ]); const SplitPercentageComponent = useMemo(() => { - if (!splitItem.isEditable) { - return {`${splitItem.percentage ?? 0}%`}; + if (splitItem.isEditable) { + return ( + + ); } - return ( - - ); + return {`${splitItem.percentage ?? 0}%`}; }, [styles, splitItem.isEditable, splitItem.percentage, onSplitExpensePercentageChange, focusHandler, onInputBlur]); return ( diff --git a/src/languages/de.ts b/src/languages/de.ts index aecebd351143a..920e83d021f1a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1118,6 +1118,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Betrag', + percent: 'Prozent', taxAmount: 'Steuerbetrag', taxRate: 'Steuersatz', approve: ({ @@ -1132,6 +1133,7 @@ const translations: TranslationDeepObject = { split: 'Teilen', splitExpense: 'Ausgabe aufteilen', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} von ${merchant}`, + splitByPercentage: 'Nach Prozentsatz aufteilen', addSplit: 'Split hinzufügen', makeSplitsEven: 'Aufteilungen angleichen', editSplits: 'Splits bearbeiten', @@ -1505,8 +1507,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Wählen Sie einen Arbeitsbereich aus', - percent: 'Prozent', - splitByPercentage: 'Nach Prozentsatz aufteilen', }, transactionMerge: { listPage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 6a17f8852ab5a..5072cb78e76c1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1120,6 +1120,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Montant', + percent: 'Pourcentage', taxAmount: 'Montant de la taxe', taxRate: "Taux d'imposition", approve: ({ @@ -1134,6 +1135,7 @@ const translations: TranslationDeepObject = { split: 'Diviser', splitExpense: 'Fractionner la dépense', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`, + splitByPercentage: 'Répartir par pourcentage', addSplit: 'Ajouter une répartition', makeSplitsEven: 'Uniformiser les répartitions', editSplits: 'Modifier les répartitions', @@ -1507,8 +1509,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Choisissez un espace de travail', - percent: 'Pourcentage', - splitByPercentage: 'Répartir par pourcentage', }, transactionMerge: { listPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index b12d4eef961f8..65eec36d07bc6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1115,6 +1115,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Importo', + percent: 'Percentuale', taxAmount: 'Importo fiscale', taxRate: 'Aliquota fiscale', approve: ({ @@ -1129,6 +1130,7 @@ const translations: TranslationDeepObject = { split: 'Dividi', splitExpense: 'Dividi spesa', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} da ${merchant}`, + splitByPercentage: 'Ripartisci per percentuale', addSplit: 'Aggiungi divisione', makeSplitsEven: 'Uniformare le suddivisioni', editSplits: 'Modifica suddivisioni', @@ -1501,8 +1503,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: "Scegli un'area di lavoro", - percent: 'Percentuale', - splitByPercentage: 'Ripartisci per percentuale', }, transactionMerge: { listPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7c7f82259cbfa..9ff29763fe640 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1117,6 +1117,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金額', + percent: 'パーセント', taxAmount: '税額', taxRate: '税率', approve: ({ @@ -1131,6 +1132,7 @@ const translations: TranslationDeepObject = { split: '分割', splitExpense: '経費を分割', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${merchant}から${amount}`, + splitByPercentage: '割合で分割', addSplit: '分割を追加', makeSplitsEven: '分割を均等にする', editSplits: '分割を編集', @@ -1501,8 +1503,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'ワークスペースを選択', - percent: 'パーセント', - splitByPercentage: '割合で分割', }, transactionMerge: { listPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index b23b6087f108e..a02e65c9a6314 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1115,6 +1115,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Bedrag', + percent: 'Procent', taxAmount: 'Belastingbedrag', taxRate: 'Belastingtarief', approve: ({ @@ -1129,6 +1130,7 @@ const translations: TranslationDeepObject = { split: 'Splitsen', splitExpense: 'Uitgave splitsen', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} van ${merchant}`, + splitByPercentage: 'Splitsen op percentage', addSplit: 'Splits toevoegen', makeSplitsEven: 'Verdelingen gelijk maken', editSplits: 'Splits bewerken', @@ -1501,8 +1503,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Kies een werkruimte', - percent: 'Procent', - splitByPercentage: 'Splitsen op percentage', }, transactionMerge: { listPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index f99a1c532ffb0..fa2c9b33a7f6a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1114,6 +1114,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Kwota', + percent: 'Procent', taxAmount: 'Kwota podatku', taxRate: 'Stawka podatkowa', approve: ({ @@ -1128,6 +1129,7 @@ const translations: TranslationDeepObject = { split: 'Podzielić', splitExpense: 'Podziel wydatek', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} od ${merchant}`, + splitByPercentage: 'Podziel procentowo', addSplit: 'Dodaj podział', makeSplitsEven: 'Wyrównaj podziały', editSplits: 'Edytuj podziały', @@ -1499,8 +1501,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Wybierz przestrzeń roboczą', - percent: 'Procent', - splitByPercentage: 'Podziel procentowo', }, transactionMerge: { listPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 4c75370532a00..d251c5d5b4312 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1115,6 +1115,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: 'Quantia', + percent: 'Porcentagem', taxAmount: 'Valor do imposto', taxRate: 'Taxa de imposto', approve: ({ @@ -1129,6 +1130,7 @@ const translations: TranslationDeepObject = { split: 'Dividir', splitExpense: 'Dividir despesa', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`, + splitByPercentage: 'Dividir por porcentagem', addSplit: 'Adicionar divisão', makeSplitsEven: 'Tornar as divisões iguais', editSplits: 'Editar divisões', @@ -1498,8 +1500,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: 'Escolha um espaço de trabalho', - percent: 'Porcentagem', - splitByPercentage: 'Dividir por porcentagem', }, transactionMerge: { listPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 117be12f2a81d..94724045b2cf1 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1102,6 +1102,7 @@ const translations: TranslationDeepObject = { }, iou: { amount: '金额', + percent: '百分比', taxAmount: '税额', taxRate: '税率', approve: ({ @@ -1116,6 +1117,7 @@ const translations: TranslationDeepObject = { split: '拆分', splitExpense: '拆分费用', splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `来自${merchant}的${amount}`, + splitByPercentage: '按百分比拆分', addSplit: '添加分账', makeSplitsEven: '使拆分均等', editSplits: '编辑拆分', @@ -1478,8 +1480,6 @@ const translations: TranslationDeepObject = { }, }, chooseWorkspace: '选择一个工作区', - percent: '百分比', - splitByPercentage: '按百分比拆分', }, transactionMerge: { listPage: { diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 6a21107d9a245..96137a740dbf1 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -102,6 +102,41 @@ function calculateSplitAmountFromPercentage(totalInCents: number, percentage: nu return Math.round((totalAbs * clamped) / 100); } +/** + * Given a list of split amounts (in backend cents) and the original total amount, + * calculate display percentages for each split so that: + * - Each row is a whole-number percentage (0–100) + * - Percentages are proportional to the absolute amounts + * - Any rounding remainder is added to the last row so the sum is always exactly 100 + */ +function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInCents: number): number[] { + const totalAbs = Math.abs(totalInCents); + + if (totalAbs <= 0 || amountsInCents.length === 0) { + return amountsInCents.map(() => 0); + } + + const rawPercentages = amountsInCents.map((amountInCents) => { + const absoluteItemAmount = Math.abs(amountInCents ?? 0); + return totalAbs > 0 ? Math.round((absoluteItemAmount / totalAbs) * 100) : 0; + }); + + const sumOfPercentages = rawPercentages.reduce((sum, current) => sum + current, 0); + const percentageRemainder = 100 - sumOfPercentages; + + if (percentageRemainder === 0) { + return rawPercentages; + } + + return rawPercentages.map((percentage, index) => { + if (index !== rawPercentages.length - 1) { + return percentage; + } + const updatedPercentage = percentage + percentageRemainder; + return Math.max(0, updatedPercentage); + }); +} + /** * The owner of the IOU report is the account who is owed money and the manager is the one who owes money! * In case the owner/manager swap, we need to update the owner of the IOU report and the report total, since it is always positive. @@ -254,6 +289,7 @@ function formatCurrentUserToAttendee(currentUser?: PersonalDetails, reportID?: s export { calculateAmount, calculateSplitAmountFromPercentage, + calculateSplitPercentagesFromAmounts, insertTagIntoTransactionTagsString, isIOUReportPendingCurrencyConversion, isMovingTransactionFromTrackExpense, diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 9954eac044525..07989b8b99b5f 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -43,7 +43,7 @@ import DateUtils from '@libs/DateUtils'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; -import {calculateSplitAmountFromPercentage} from '@libs/IOUUtils'; +import {calculateSplitAmountFromPercentage, calculateSplitPercentagesFromAmounts} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SplitExpenseParamList} from '@libs/Navigation/types'; @@ -284,17 +284,19 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const dotSeparator: TranslationPathOrText = {text: ` ${CONST.DOT_SEPARATOR} `}; const isTransactionMadeWithCard = isManagedCardTransaction(transaction); const showCashOrCard: TranslationPathOrText = {translationPath: isTransactionMadeWithCard ? 'iou.card' : 'iou.cash'}; - const totalAbs = Math.abs(transactionDetailsAmount); + const splitExpensesArray = draftTransaction?.comment?.splitExpenses ?? []; - const items: SplitListItemType[] = (draftTransaction?.comment?.splitExpenses ?? []).map((item): SplitListItemType => { + const splitAmounts = splitExpensesArray.map((item) => Number(item.amount ?? 0)); + const adjustedPercentages = calculateSplitPercentagesFromAmounts(splitAmounts, transactionDetailsAmount); + + const items: SplitListItemType[] = splitExpensesArray.map((item, index): SplitListItemType => { const previewHeaderText: TranslationPathOrText[] = [showCashOrCard]; const currentTransaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${item?.transactionID}`]; const currentReport = getReportOrDraftReport(currentTransaction?.reportID); const isApproved = isReportApproved({report: currentReport}); const isSettled = isSettledReportUtils(currentReport?.reportID); const isCancelled = currentReport && currentReport?.isCancelledIOU; - const absoluteItemAmount = Math.abs(Number(item.amount ?? 0)); - const percentage = totalAbs > 0 ? Math.round((absoluteItemAmount / totalAbs) * 100) : 0; + const percentage = adjustedPercentages.at(index) ?? 0; const date = DateUtils.formatWithUTCTimeZone( item.created, diff --git a/src/styles/index.ts b/src/styles/index.ts index 643e94339a0e2..56375fa61e861 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1242,6 +1242,21 @@ const staticStyles = (theme: ThemeColors) => textAlign: 'right', }, + optionRowAmountInputContainer: { + width: variables.splitExpenseAmountWidth, + lineHeight: undefined, + marginLeft: 1, + padding: 0, + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'center', + textAlign: 'left', + }, + + optionRowAmountMobileInputContainer: { + width: variables.splitExpenseAmountMobileWidth, + }, + optionRowPercentInputContainer: { width: variables.splitExpensePercentageMobileWidth, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 86b3166800805..f77afcc1b54f8 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -37,6 +37,7 @@ import {compactContentContainerStyles} from './optionRowStyles'; import positioning from './positioning'; import searchHeaderDefaultOffset from './searchHeaderDefaultOffset'; import getSearchPageNarrowHeaderStyles from './searchPageNarrowHeaderStyles'; +import splitAmountInputStyles from './splitAmountInputStyles'; import splitPercentageInputStyles from './splitPercentageInputStyles'; import type { AllStyles, @@ -1334,6 +1335,7 @@ const staticStyleUtils = { getNavigationModalCardStyle, getCardStyles, getSearchPageNarrowHeaderStyles, + splitAmountInputStyles, splitPercentageInputStyles, getOpacityStyle, getMultiGestureCanvasContainerStyle, diff --git a/src/styles/utils/splitAmountInputStyles/index.native.ts b/src/styles/utils/splitAmountInputStyles/index.native.ts new file mode 100644 index 0000000000000..3605675c891a9 --- /dev/null +++ b/src/styles/utils/splitAmountInputStyles/index.native.ts @@ -0,0 +1,5 @@ +import type SplitAmountInputStyles from './types'; + +const splitAmountInputStyles: SplitAmountInputStyles = (styles) => [styles.optionRowAmountMobileInputContainer]; + +export default splitAmountInputStyles; diff --git a/src/styles/utils/splitAmountInputStyles/index.ts b/src/styles/utils/splitAmountInputStyles/index.ts new file mode 100644 index 0000000000000..90aa05569d7fc --- /dev/null +++ b/src/styles/utils/splitAmountInputStyles/index.ts @@ -0,0 +1,4 @@ +import type SplitPercentageInputStyles from './types'; + +const splitPercentageInputStyles: SplitPercentageInputStyles = (styles, isSmallScreenWidth = false) => [isSmallScreenWidth ? styles.optionRowAmountMobileInputContainer : {}]; +export default splitPercentageInputStyles; diff --git a/src/styles/utils/splitAmountInputStyles/types.ts b/src/styles/utils/splitAmountInputStyles/types.ts new file mode 100644 index 0000000000000..b08018bc939ab --- /dev/null +++ b/src/styles/utils/splitAmountInputStyles/types.ts @@ -0,0 +1,6 @@ +import type {ViewStyle} from 'react-native'; +import type {ThemeStyles} from '@styles/index'; + +type SplitAmountInputStyles = (styles: ThemeStyles, isSmallScreenWidth: boolean) => ViewStyle[]; + +export default SplitAmountInputStyles; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 9d2251bf2568d..b79add12c9dc6 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -382,6 +382,8 @@ export default { uberEmptyListIconHeight: 136, // Split expense tabs + splitExpenseAmountWidth: 62, + splitExpenseAmountMobileWidth: 82, splitExpensePercentageWidth: 42, splitExpensePercentageMobileWidth: 62, } as const; diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index cc7cbd556ff10..9a3f853f6da3c 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -186,6 +186,32 @@ describe('IOUUtils', () => { }); }); + describe('calculateSplitPercentagesFromAmounts', () => { + test('Distributes percentages proportionally and adjusts remainder on last item', () => { + // 23.00 split as 7.66, 7.66, 7.68 → 3x ~33% but must sum to 100 + const totalInCents = 2300; + const amounts = [766, 766, 768]; + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); + + expect(percentages).toEqual([33, 33, 34]); + expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); + }); + + test('Handles zero or empty totals by returning zeros', () => { + expect(IOUUtils.calculateSplitPercentagesFromAmounts([], 0)).toEqual([]); + expect(IOUUtils.calculateSplitPercentagesFromAmounts([0, 0], 0)).toEqual([0, 0]); + }); + + test('Uses absolute values of amounts and total', () => { + const totalInCents = -2300; + const amounts = [-766, -766, -768]; + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); + + expect(percentages).toEqual([33, 33, 34]); + expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); + }); + }); + describe('insertTagIntoTransactionTagsString', () => { test('Inserting a tag into tag string should update the tag', () => { expect(IOUUtils.insertTagIntoTransactionTagsString(':NY:Texas', 'California', 2, true)).toBe(':NY:California'); From fc60538f4ec889a2af720efcf1871104674cc8bf Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 14 Nov 2025 18:27:02 -0800 Subject: [PATCH 09/27] eslint --- .../Icon/chunks/expensify-icons.chunk.ts | 2 ++ .../SplitListItem.tsx | 15 ++++++---- src/pages/iou/SplitExpensePage.tsx | 30 ++++++++++++------- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts index 61d793bcc2c26..4375fddcf0a69 100644 --- a/src/components/Icon/chunks/expensify-icons.chunk.ts +++ b/src/components/Icon/chunks/expensify-icons.chunk.ts @@ -158,6 +158,7 @@ import Offline from '@assets/images/offline.svg'; import Paperclip from '@assets/images/paperclip.svg'; import Pause from '@assets/images/pause.svg'; import Pencil from '@assets/images/pencil.svg'; +import Percent from '@assets/images/percent.svg'; import Phone from '@assets/images/phone.svg'; import Pin from '@assets/images/pin.svg'; import Plane from '@assets/images/plane.svg'; @@ -355,6 +356,7 @@ const Expensicons = { Paperclip, Pause, Pencil, + Percent, Phone, Pin, Play, diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index 1673cc5a72218..72ab863f969c2 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -1,11 +1,10 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; -import {Folder, Tag} from '@components/Icon/Expensicons'; -import * as Expensicons from '@components/Icon/Expensicons'; import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; import PercentageForm from '@components/PercentageForm'; import Text from '@components/Text'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -34,7 +33,9 @@ function SplitListItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); + const {Folder, Tag, ArrowRight} = useMemoizedLazyExpensifyIcons(['Folder', 'Tag', 'ArrowRight'] as const); const splitItem = item as unknown as SplitListItemType; @@ -44,7 +45,7 @@ function SplitListItem({ (amount: string) => { splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount)); }, - [splitItem.transactionID, splitItem.onSplitExpenseAmountChange], + [splitItem], ); const onSplitExpensePercentageChange = useCallback( @@ -52,7 +53,7 @@ function SplitListItem({ const percentageNumber = Number(value || 0); splitItem.onSplitExpensePercentageChange?.(splitItem.transactionID, Number.isNaN(percentageNumber) ? 0 : percentageNumber); }, - [splitItem.transactionID, splitItem.onSplitExpensePercentageChange], + [splitItem], ); const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0); @@ -121,6 +122,8 @@ function SplitListItem({ ); }, [ + StyleUtils, + isSmallScreenWidth, styles, contentWidth, prefixCharacterMargin, @@ -149,7 +152,7 @@ function SplitListItem({ ); } return {`${splitItem.percentage ?? 0}%`}; - }, [styles, splitItem.isEditable, splitItem.percentage, onSplitExpensePercentageChange, focusHandler, onInputBlur]); + }, [StyleUtils, styles, splitItem.isEditable, splitItem.percentage, onSplitExpensePercentageChange, focusHandler, onInputBlur]); return ( ({ {!splitItem.isEditable ? null : ( diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 07989b8b99b5f..dad1947397666 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -2,14 +2,12 @@ import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, Keyboard, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; -import type {SvgProps} from 'react-native-svg/lib/typescript/ReactNativeSVG'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; import FormHelpMessage from '@components/FormHelpMessage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; @@ -20,6 +18,7 @@ import getOpacity from '@components/TabSelector/getOpacity'; import TabSelectorItem from '@components/TabSelector/TabSelectorItem'; import useDisplayFocusedInputUnderKeyboard from '@hooks/useDisplayFocusedInputUnderKeyboard'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -64,7 +63,6 @@ type TabType = { key: ValueOf; testID: string; titleKey: TranslationPaths; - icon: React.FC; }; const tabs: TabType[] = [ @@ -72,19 +70,18 @@ const tabs: TabType[] = [ key: CONST.IOU.SPLIT_TYPE.AMOUNT, testID: `split-expense-tab-${CONST.IOU.SPLIT_TYPE.AMOUNT}`, titleKey: 'iou.amount', - icon: Expensicons.MoneyCircle, }, { key: CONST.IOU.SPLIT_TYPE.PERCENTAGE, testID: `split-expense-tab-${CONST.IOU.SPLIT_TYPE.PERCENTAGE}`, titleKey: 'iou.percent', - icon: Expensicons.Percent, }, ]; function SplitExpensePage({route}: SplitExpensePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const {ArrowsLeftRight, MoneyCircle, Percent, Plus} = useMemoizedLazyExpensifyIcons(['Plus', 'ArrowsLeftRight', 'MoneyCircle', 'Percent'] as const); const {listRef, viewRef, footerRef, bottomOffset, scrollToFocusedInput, SplitListItem} = useDisplayFocusedInputUnderKeyboard(); const {reportID, transactionID, splitExpenseTransactionID, backTo} = route.params; @@ -361,20 +358,33 @@ function SplitExpensePage({route}: SplitExpensePageProps) { {shouldShowMakeSplitsEven && ( )} ); - }, [onAddSplitExpense, onMakeSplitsEven, translate, shouldShowMakeSplitsEven, shouldUseNarrowLayout, styles.w100, styles.ph4, styles.flexColumn, styles.mt1, styles.mb3]); + }, [ + onAddSplitExpense, + onMakeSplitsEven, + translate, + shouldShowMakeSplitsEven, + shouldUseNarrowLayout, + styles.w100, + styles.ph4, + styles.flexColumn, + styles.mt1, + styles.mb3, + ArrowsLeftRight, + Plus, + ]); const footerContent = useMemo(() => { const shouldShowWarningMessage = sumOfSplitExpenses < transactionDetailsAmount; @@ -428,7 +438,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { { @@ -449,7 +459,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { })} ); - }, [isPercentageMode, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, shouldShowMakeSplitsEven, translate]); + }, [isPercentageMode, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, shouldShowMakeSplitsEven, translate, MoneyCircle, Percent]); const headerTitle = useMemo(() => { if (splitExpenseTransactionID) { From b088aefc55466f3174943e32fa1d17d05681403e Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 17 Nov 2025 13:11:16 -0800 Subject: [PATCH 10/27] resolved review comments (2) --- .../SplitListItem.tsx | 25 +++--------- .../SelectionListWithSections/types.ts | 13 +++---- src/libs/IOUUtils.ts | 31 ++++++++------- src/pages/iou/SplitExpensePage.tsx | 38 +++++++------------ src/styles/index.ts | 1 + src/styles/utils/spacing.ts | 4 -- tests/unit/IOUUtilsTest.ts | 11 ++++++ 7 files changed, 56 insertions(+), 67 deletions(-) diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index 72ab863f969c2..3bcb05c656983 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -41,20 +41,7 @@ function SplitListItem({ const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency); - const onSplitExpenseAmountChange = useCallback( - (amount: string) => { - splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount)); - }, - [splitItem], - ); - - const onSplitExpensePercentageChange = useCallback( - (value: string) => { - const percentageNumber = Number(value || 0); - splitItem.onSplitExpensePercentageChange?.(splitItem.transactionID, Number.isNaN(percentageNumber) ? 0 : percentageNumber); - }, - [splitItem], - ); + const onSplitExpenseValueChange = useCallback((value: string) => splitItem.onSplitExpenseValueChange(splitItem.transactionID, Number(value), splitItem.mode), [splitItem]); const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0); @@ -85,7 +72,7 @@ function SplitListItem({ hideCurrencySymbol submitBehavior="blurAndSubmit" formatAmountOnBlur - onAmountChange={onSplitExpenseAmountChange} + onAmountChange={onSplitExpenseValueChange} prefixContainerStyle={[styles.pl1, styles.pv0, styles.h100]} prefixStyle={styles.lineHeightUndefined} inputStyle={styles.optionRowAmountInputContainer} @@ -132,7 +119,7 @@ function SplitListItem({ splitItem.currency, splitItem.currencySymbol, formattedOriginalAmount.length, - onSplitExpenseAmountChange, + onSplitExpenseValueChange, focusHandler, onInputBlur, ]); @@ -141,18 +128,18 @@ function SplitListItem({ if (splitItem.isEditable) { return ( ); } return {`${splitItem.percentage ?? 0}%`}; - }, [StyleUtils, styles, splitItem.isEditable, splitItem.percentage, onSplitExpensePercentageChange, focusHandler, onInputBlur]); + }, [StyleUtils, styles, splitItem.isEditable, splitItem.percentage, onSplitExpenseValueChange, focusHandler, onInputBlur]); return ( void; - /** Current mode for the split editor: amount or percentage */ - mode?: ValueOf; + mode: ValueOf; /** Percentage value to show when in percentage mode (0-100) */ - percentage?: number; + percentage: number; - /** Function for updating percentage */ - onSplitExpensePercentageChange?: (currentItemTransactionID: string, percentage: number) => void; + /** + * Function for updating value (amount or percentage based on mode) + */ + onSplitExpenseValueChange: (transactionID: string, value: number, mode: ValueOf) => void; }; type SplitListItemProps = ListItemProps; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 96137a740dbf1..2e1d36af23db4 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -103,11 +103,14 @@ function calculateSplitAmountFromPercentage(totalInCents: number, percentage: nu } /** - * Given a list of split amounts (in backend cents) and the original total amount, - * calculate display percentages for each split so that: - * - Each row is a whole-number percentage (0–100) - * - Percentages are proportional to the absolute amounts - * - Any rounding remainder is added to the last row so the sum is always exactly 100 + * Given a list of split amounts (in backend cents) and the original total amount, calculate display percentages + * for each split so that: + * - Each row is a whole-number percentage of the original total + * - When the sum of split amounts exactly matches the original total, percentages are proportional to the amounts + * and rounded so that the sum of percentages is exactly 100 (any rounding remainder is applied to the last row) + * - When the sum of split amounts does not match the original total (over/under splits), percentages still reflect + * each amount as a percentage of the original total and may sum to something other than 100; this keeps + * user-entered percentages stable while a validation error highlights the mismatch */ function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInCents: number): number[] { const totalAbs = Math.abs(totalInCents); @@ -116,23 +119,25 @@ function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInC return amountsInCents.map(() => 0); } - const rawPercentages = amountsInCents.map((amountInCents) => { - const absoluteItemAmount = Math.abs(amountInCents ?? 0); - return totalAbs > 0 ? Math.round((absoluteItemAmount / totalAbs) * 100) : 0; - }); - + const amountsAbs = amountsInCents.map((amount) => Math.abs(amount ?? 0)); + const rawPercentages = amountsAbs.map((amount) => (totalAbs > 0 ? Math.round((amount / totalAbs) * 100) : 0)); const sumOfPercentages = rawPercentages.reduce((sum, current) => sum + current, 0); - const percentageRemainder = 100 - sumOfPercentages; + const amountsTotal = amountsAbs.reduce((sum, curr) => sum + curr, 0); - if (percentageRemainder === 0) { + // If the split amounts don't add up to the original total, or the rounded percentages already sum to 100, + // return the raw percentages. This allows user-entered percentages (and their corresponding amounts) to + // remain stable even when the splits are over/under the original total. + if (amountsTotal !== totalAbs || sumOfPercentages === 100) { return rawPercentages; } + const remainder = 100 - sumOfPercentages; + return rawPercentages.map((percentage, index) => { if (index !== rawPercentages.length - 1) { return percentage; } - const updatedPercentage = percentage + percentageRemainder; + const updatedPercentage = percentage + remainder; return Math.max(0, updatedPercentage); }); } diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index dad1947397666..e8a7fed763e96 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -94,7 +94,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const affectedAnimatedTabs = useMemo(() => Array.from({length: tabs.length}, (v, i) => i), []); const [selectorWidth, setSelectorWidth] = React.useState(0); const [selectorX, setSelectorX] = React.useState(0); - const tabSelectorViewRef = useRef(null); + const tabSelectorViewRef = useRef(null); const [isPercentageMode, setIsPercentageMode] = useState(false); const {currentSearchHash} = useSearchContext(); const theme = useTheme(); @@ -260,21 +260,19 @@ function SplitExpensePage({route}: SplitExpensePageProps) { isBetaEnabled, ]); - const onSplitExpenseAmountChange = useCallback( - (currentItemTransactionID: string, value: number) => { - const amountInCents = convertToBackendAmount(value); - updateSplitExpenseAmountField(draftTransaction, currentItemTransactionID, amountInCents); - }, - [draftTransaction], - ); - - const onSplitExpensePercentageChange = useCallback( - (currentItemTransactionID: string, percentage: number) => { - const amountInCents = calculateSplitAmountFromPercentage(transactionDetailsAmount, percentage); - updateSplitExpenseAmountField(draftTransaction, currentItemTransactionID, amountInCents); + const onSplitExpenseValueChange = useCallback( + (id: string, value: number, mode: ValueOf) => { + if (mode === CONST.IOU.SPLIT_TYPE.AMOUNT) { + const amountInCents = convertToBackendAmount(value); + updateSplitExpenseAmountField(draftTransaction, id, amountInCents); + } else { + const amountInCents = calculateSplitAmountFromPercentage(transactionDetailsAmount, value); + updateSplitExpenseAmountField(draftTransaction, id, amountInCents); + } }, [draftTransaction, transactionDetailsAmount], ); + const getTranslatedText = useCallback((item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : (item.text ?? '')), [translate]); const [sections] = useMemo(() => { @@ -322,10 +320,9 @@ function SplitExpensePage({route}: SplitExpensePageProps) { currency: draftTransaction?.currency ?? CONST.CURRENCY.USD, transactionID: item?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, currencySymbol, - onSplitExpenseAmountChange, mode: isPercentageMode ? CONST.IOU.SPLIT_TYPE.PERCENTAGE : CONST.IOU.SPLIT_TYPE.AMOUNT, percentage, - onSplitExpensePercentageChange, + onSplitExpenseValueChange, isSelected: splitExpenseTransactionID === item.transactionID, keyForList: item?.transactionID, isEditable: (item.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.CLOSED, @@ -342,12 +339,11 @@ function SplitExpensePage({route}: SplitExpensePageProps) { allTransactions, transactionDetailsAmount, currencySymbol, - onSplitExpenseAmountChange, - onSplitExpensePercentageChange, isPercentageMode, splitExpenseTransactionID, translate, getTranslatedText, + onSplitExpenseValueChange, ]); const shouldShowMakeSplitsEven = useMemo(() => childTransactions.length === 0, [childTransactions]); @@ -441,13 +437,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { icon={tab.key === CONST.IOU.SPLIT_TYPE.AMOUNT ? MoneyCircle : Percent} title={translate(tab.titleKey)} isActive={isActive} - onPress={() => { - if (tab.key === CONST.IOU.SPLIT_TYPE.AMOUNT) { - setIsPercentageMode(false); - } else { - setIsPercentageMode(true); - } - }} + onPress={() => setIsPercentageMode(tab.key === CONST.IOU.SPLIT_TYPE.PERCENTAGE)} shouldShowLabelWhenInactive backgroundColor={backgroundColor} inactiveOpacity={inactiveOpacity} diff --git a/src/styles/index.ts b/src/styles/index.ts index 56375fa61e861..ac1b8fd96afb0 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1264,6 +1264,7 @@ const staticStyles = (theme: ThemeColors) => optionRowPercentInput: { width: variables.splitExpensePercentageWidth, textAlign: 'right', + marginRight: 2, }, textInputLabelContainer: { diff --git a/src/styles/utils/spacing.ts b/src/styles/utils/spacing.ts index 018074da59c10..e0c5ec07c68fe 100644 --- a/src/styles/utils/spacing.ts +++ b/src/styles/utils/spacing.ts @@ -131,10 +131,6 @@ export default { marginRight: 0, }, - mrHalf: { - marginRight: 2, - }, - mr1: { marginRight: 4, }, diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 9a3f853f6da3c..241956571aee9 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -210,6 +210,17 @@ describe('IOUUtils', () => { expect(percentages).toEqual([33, 33, 34]); expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); }); + + test('Keeps raw percentages when split totals differ from original total', () => { + const originalTotalInCents = 20000; + const amounts = [10000, 10000, 5000]; // totals 25000, larger than original total + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, originalTotalInCents); + + // Each amount is expressed as a percentage of the original total + expect(percentages).toEqual([50, 50, 25]); + // The sum can exceed 100 when splits are over the original total; the validation error covers this + expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(125); + }); }); describe('insertTagIntoTransactionTagsString', () => { From b8618fbd3388c7b40d5793cebd04ed25942e06a0 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 18 Nov 2025 20:13:28 -0800 Subject: [PATCH 11/27] resolved review comments (3) --- src/components/PercentageForm.tsx | 13 +- .../SplitListItem.tsx | 20 +- src/components/TabSelector/TabSelector.tsx | 143 +++++-------- .../TabSelector/TabSelectorBase.tsx | 192 ++++++++++++++++++ src/libs/IOUUtils.ts | 57 ++++-- src/libs/MoneyRequestUtils.ts | 13 +- src/pages/iou/SplitExpensePage.tsx | 66 ++---- src/styles/index.ts | 1 - tests/unit/IOUUtilsTest.ts | 14 +- tests/unit/MoneyRequestUtilsTest.ts | 29 ++- 10 files changed, 375 insertions(+), 173 deletions(-) create mode 100644 src/components/TabSelector/TabSelectorBase.tsx diff --git a/src/components/PercentageForm.tsx b/src/components/PercentageForm.tsx index d9636e96d1d64..73cc31e258dcb 100644 --- a/src/components/PercentageForm.tsx +++ b/src/components/PercentageForm.tsx @@ -19,11 +19,14 @@ type PercentageFormProps = BaseTextInputProps & { /** Custom label for the TextInput */ label?: string; + /** Whether to allow values greater than 100 (e.g. split expenses in percentage mode). */ + allowExceedingHundred?: boolean; + /** Reference to the outer element */ ref?: ForwardedRef; }; -function PercentageForm({value: amount, errorText, onInputChange, label, ref, ...rest}: PercentageFormProps) { +function PercentageForm({value: amount, errorText, onInputChange, label, allowExceedingHundred = false, ref, ...rest}: PercentageFormProps) { const {toLocaleDigit, numberFormat} = useLocalize(); const textInput = useRef(null); @@ -31,7 +34,7 @@ function PercentageForm({value: amount, errorText, onInputChange, label, ref, .. const currentAmount = useMemo(() => (typeof amount === 'string' ? amount : ''), [amount]); /** - * Sets the selection and the amount accordingly to the value passed to the input + * Sets the amount according to the value passed to the input * @param newAmount - Changed amount from user input */ const setNewAmount = useCallback( @@ -39,16 +42,14 @@ function PercentageForm({value: amount, errorText, onInputChange, label, ref, .. // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value // More info: https://github.com/Expensify/App/issues/16974 const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); - // Use a shallow copy of selection to trigger setSelection - // More info: https://github.com/Expensify/App/issues/16385 - if (!validatePercentage(newAmountWithoutSpaces)) { + if (!validatePercentage(newAmountWithoutSpaces, allowExceedingHundred)) { return; } const strippedAmount = stripCommaFromAmount(newAmountWithoutSpaces); onInputChange?.(strippedAmount); }, - [onInputChange], + [allowExceedingHundred, onInputChange], ); const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index 3bcb05c656983..e42b769125830 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -47,6 +47,7 @@ function SplitListItem({ const [prefixCharacterMargin, setPrefixCharacterMargin] = useState(CONST.CHARACTER_WIDTH); const contentWidth = (formattedOriginalAmount.length + 1) * CONST.CHARACTER_WIDTH; + const [percentageDraft, setPercentageDraft] = useState(); const focusHandler = useCallback(() => { if (!onInputFocus) { return; @@ -125,21 +126,32 @@ function SplitListItem({ ]); const SplitPercentageComponent = useMemo(() => { + const displayedPercentage = percentageDraft ?? String(splitItem.percentage ?? 0); + if (splitItem.isEditable) { return ( { + setPercentageDraft(value); + onSplitExpenseValueChange(value); + }} + value={displayedPercentage} textInputContainerStyles={StyleUtils.splitPercentageInputStyles(styles)} containerStyles={styles.optionRowPercentInputContainer} inputStyle={[styles.optionRowPercentInput, styles.lineHeightUndefined]} onFocus={focusHandler} - onBlur={onInputBlur} + onBlur={(event) => { + setPercentageDraft(undefined); + if (onInputBlur) { + onInputBlur(event); + } + }} + allowExceedingHundred /> ); } return {`${splitItem.percentage ?? 0}%`}; - }, [StyleUtils, styles, splitItem.isEditable, splitItem.percentage, onSplitExpenseValueChange, focusHandler, onInputBlur]); + }, [StyleUtils, styles, splitItem.isEditable, splitItem.percentage, percentageDraft, onSplitExpenseValueChange, focusHandler, onInputBlur]); return ( Array.from({length: state.routes.length}, (v, i) => i), [state.routes.length]); - const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs); - const viewRef = useRef(null); - const [selectorWidth, setSelectorWidth] = React.useState(0); - const [selectorX, setSelectorX] = React.useState(0); - - const isResizing = useIsResizing(); - - useEffect(() => { - // It is required to wait transition end to reset affectedAnimatedTabs because tabs style is still animating during transition. - setTimeout(() => { - setAffectedAnimatedTabs(defaultAffectedAnimatedTabs); - }, CONST.ANIMATED_TRANSITION); - }, [defaultAffectedAnimatedTabs, state.index]); - - const measure = useCallback(() => { - viewRef.current?.measureInWindow((x, _y, width) => { - setSelectorX(x); - setSelectorWidth(width); - }); - }, [viewRef]); - useLayoutEffect(() => { - // measure location/width after animation completes - setTimeout(() => { - measure(); - }, CONST.TOOLTIP_ANIMATION_DURATION); - }, [measure]); + const tabs: TabSelectorBaseItem[] = useMemo( + () => + state.routes.map((route) => { + const {icon, title, testID} = getIconTitleAndTestID(route.name, translate); + return { + key: route.name, + icon, + title, + testID, + }; + }), + [state.routes, translate], + ); + + const activeRouteName = state.routes[state.index]?.name ?? ''; + + const handleTabPress = (tabKey: string) => { + const route = state.routes.find((candidateRoute) => candidateRoute.name === tabKey); + if (!route) { + return; + } - useEffect(() => { - if (isResizing) { + const isActive = route.key === state.routes[state.index]?.key; + if (isActive) { return; } - // Re-measure when resizing ends - // This is necessary to ensure the tooltip is positioned correctly after resizing - measure(); - }, [measure, isResizing]); + + const event = navigation.emit({ + type: 'tabPress', + target: route.key, + canPreventDefault: true, + }); + + if (!event.defaultPrevented) { + navigation.dispatch(TabActions.jumpTo(route.name)); + } + + onTabPress(route.name); + }; return ( - - {state.routes.map((route, index) => { - const isActive = index === state.index; - const activeOpacity = getOpacity({routesLength: state.routes.length, tabIndex: index, active: true, affectedTabs: affectedAnimatedTabs, position, isActive}); - const inactiveOpacity = getOpacity({routesLength: state.routes.length, tabIndex: index, active: false, affectedTabs: affectedAnimatedTabs, position, isActive}); - const backgroundColor = getBackgroundColor({routesLength: state.routes.length, tabIndex: index, affectedTabs: affectedAnimatedTabs, theme, position, isActive}); - const {icon, title, testID} = getIconTitleAndTestID(route.name, translate); - const onPress = () => { - if (isActive) { - return; - } - - setAffectedAnimatedTabs([state.index, index]); - - const event = navigation.emit({ - type: 'tabPress', - target: route.key, - canPreventDefault: true, - }); - - if (!event.defaultPrevented) { - navigation.dispatch(TabActions.jumpTo(route.name)); - } - - onTabPress(route.name); - }; - - return ( - - ); - })} - + ); } diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx new file mode 100644 index 0000000000000..7a42175d71353 --- /dev/null +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -0,0 +1,192 @@ +import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import {View} from 'react-native'; +import useIsResizing from '@hooks/useIsResizing'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; +import getBackgroundColor from './getBackground'; +import getOpacity from './getOpacity'; +import TabSelectorItem from './TabSelectorItem'; + +/** + * Navigation-agnostic tab selector UI that renders a row of TabSelectorItem components. + * + * This component owns the shared layout, width/position measurements, and animation helpers + * (getOpacity / getBackgroundColor). It is reused by both navigation-based TabSelector and + * inline tab selectors like SplitExpensePage. + */ + +type TabSelectorBaseItem = { + /** Stable key for the tab. */ + key: string; + + /** Icon to display on the tab. */ + icon?: IconAsset; + + /** Localized title to display. */ + title: string; + + /** Test identifier used to find elements in tests. */ + testID?: string; +}; + +type TabSelectorBaseProps = { + /** Tabs to render. */ + tabs: TabSelectorBaseItem[]; + + /** Key of the currently active tab. */ + activeTabKey: string; + + /** Called when a tab is pressed with its key. */ + onTabPress?: (key: string) => void; + + /** Animated position from a navigator (optional). */ + // eslint-disable-next-line no-restricted-imports + position?: import('react-native').Animated.AnimatedInterpolation; + + /** Whether to show the label when the tab is inactive. */ + shouldShowLabelWhenInactive?: boolean; + + /** Whether tabs should have equal width. */ + equalWidth?: boolean; + + /** Determines whether the product training tooltip should be displayed to the user. */ + shouldShowProductTrainingTooltip?: boolean; + + /** Function to render the content of the product training tooltip. */ + renderProductTrainingTooltip?: () => React.JSX.Element; +}; + +function TabSelectorBase({ + tabs, + activeTabKey, + onTabPress = () => {}, + position, + shouldShowLabelWhenInactive = true, + equalWidth = false, + shouldShowProductTrainingTooltip = false, + renderProductTrainingTooltip, +}: TabSelectorBaseProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const isResizing = useIsResizing(); + + const routesLength = tabs.length; + + const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: routesLength}, (_v, i) => i), [routesLength]); + const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs); + const viewRef = useRef(null); + const [selectorWidth, setSelectorWidth] = useState(0); + const [selectorX, setSelectorX] = useState(0); + + const activeIndex = useMemo(() => tabs.findIndex((tab) => tab.key === activeTabKey), [tabs, activeTabKey]); + + // After a tab change, reset affectedAnimatedTabs once the transition is done so + // tabs settle back into the default animated state. + useEffect(() => { + const timerID = setTimeout(() => { + setAffectedAnimatedTabs(defaultAffectedAnimatedTabs); + }, CONST.ANIMATED_TRANSITION); + + return () => clearTimeout(timerID); + }, [defaultAffectedAnimatedTabs, activeIndex]); + + const measure = useCallback(() => { + viewRef.current?.measureInWindow((x, _y, width) => { + setSelectorX(x); + setSelectorWidth(width); + }); + }, []); + + // Measure location/width after initial mount and when layout animations settle. + useLayoutEffect(() => { + const timerID = setTimeout(() => { + measure(); + }, CONST.TOOLTIP_ANIMATION_DURATION); + + return () => clearTimeout(timerID); + }, [measure]); + + // Re-measure when resizing ends so tooltips and equal-width layouts stay aligned. + useEffect(() => { + if (isResizing) { + return; + } + measure(); + }, [measure, isResizing]); + + return ( + + {tabs.map((tab, index) => { + const isActive = index === activeIndex; + const activeOpacity = getOpacity({ + routesLength, + tabIndex: index, + active: true, + affectedTabs: affectedAnimatedTabs, + position, + isActive, + }); + const inactiveOpacity = getOpacity({ + routesLength, + tabIndex: index, + active: false, + affectedTabs: affectedAnimatedTabs, + position, + isActive, + }); + const backgroundColor = getBackgroundColor({ + routesLength, + tabIndex: index, + affectedTabs: affectedAnimatedTabs, + theme, + position, + isActive, + }); + + const handlePress = () => { + if (isActive) { + return; + } + + if (activeIndex >= 0) { + setAffectedAnimatedTabs([activeIndex, index]); + } else { + setAffectedAnimatedTabs([index]); + } + + onTabPress(tab.key); + }; + + return ( + + ); + })} + + ); +} + +TabSelectorBase.displayName = 'TabSelectorBase'; + +export default TabSelectorBase; +export type {TabSelectorBaseItem, TabSelectorBaseProps}; diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 2e1d36af23db4..1671cc7ebbb2c 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -107,10 +107,12 @@ function calculateSplitAmountFromPercentage(totalInCents: number, percentage: nu * for each split so that: * - Each row is a whole-number percentage of the original total * - When the sum of split amounts exactly matches the original total, percentages are proportional to the amounts - * and rounded so that the sum of percentages is exactly 100 (any rounding remainder is applied to the last row) + * and rounded so that the sum of percentages is exactly 100. Any rounding remainder is allocated entirely to the + * row with the largest amount (breaking ties by using the last such row), so the first N rows share the same + * base percentage and any leftover remainder is applied to the last row. * - When the sum of split amounts does not match the original total (over/under splits), percentages still reflect * each amount as a percentage of the original total and may sum to something other than 100; this keeps - * user-entered percentages stable while a validation error highlights the mismatch + * user-entered percentages stable while a validation error highlights the mismatch. */ function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInCents: number): number[] { const totalAbs = Math.abs(totalInCents); @@ -120,26 +122,49 @@ function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInC } const amountsAbs = amountsInCents.map((amount) => Math.abs(amount ?? 0)); - const rawPercentages = amountsAbs.map((amount) => (totalAbs > 0 ? Math.round((amount / totalAbs) * 100) : 0)); - const sumOfPercentages = rawPercentages.reduce((sum, current) => sum + current, 0); + + // First compute rounded percentages used when we want to preserve the user's distribution, e.g. when the + // split amounts do not add up to the original total. + const roundedPercentages = amountsAbs.map((amount) => (totalAbs > 0 ? Math.round((amount / totalAbs) * 100) : 0)); + const sumOfRoundedPercentages = roundedPercentages.reduce((sum, current) => sum + current, 0); const amountsTotal = amountsAbs.reduce((sum, curr) => sum + curr, 0); - // If the split amounts don't add up to the original total, or the rounded percentages already sum to 100, - // return the raw percentages. This allows user-entered percentages (and their corresponding amounts) to - // remain stable even when the splits are over/under the original total. - if (amountsTotal !== totalAbs || sumOfPercentages === 100) { - return rawPercentages; + // If the split amounts don't add up to the original total, return rounded percentages as-is so user-entered + // percentages and amounts remain stable while a validation error highlights the mismatch. + if (amountsTotal !== totalAbs) { + return roundedPercentages; + } + + // If rounded percentages already sum to 100, we can also return them directly. + if (sumOfRoundedPercentages === 100) { + return roundedPercentages; } - const remainder = 100 - sumOfPercentages; + // Otherwise, compute base percentages by flooring the exact percentages and then allocating the full remainder + // to the row with the largest amount (last one in case of ties). This ensures the first N rows share the same + // base percentage and any leftover is applied entirely to the last row, matching how we treat remainder in + // split amounts. + const flooredPercentages = amountsAbs.map((amount) => (totalAbs > 0 ? Math.floor((amount / totalAbs) * 100) : 0)); + const sumOfFlooredPercentages = flooredPercentages.reduce((sum, current) => sum + current, 0); + const remainder = 100 - sumOfFlooredPercentages; + + if (remainder === 0) { + return flooredPercentages; + } - return rawPercentages.map((percentage, index) => { - if (index !== rawPercentages.length - 1) { - return percentage; + const maxAmount = Math.max(...amountsAbs); + let lastMaxIndex = 0; + for (let i = 0; i < amountsAbs.length; i += 1) { + if (amountsAbs.at(i) === maxAmount) { + lastMaxIndex = i; } - const updatedPercentage = percentage + remainder; - return Math.max(0, updatedPercentage); - }); + } + + const adjustedPercentages = [...flooredPercentages]; + const baseValue = adjustedPercentages.at(lastMaxIndex) ?? 0; + adjustedPercentages[lastMaxIndex] = Math.max(0, baseValue + remainder); + + return adjustedPercentages; } /** diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index ade3c386078d5..18db07a30199c 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -54,9 +54,18 @@ function validateAmount(amount: string, decimals: number, amountMaxLength: numbe } /** - * Check if percentage is between 0 and 100 + * Basic validation for percentage input. + * + * By default we keep backwards-compatible behavior and only allow whole-number percentages between 0 and 100. + * Some callers (e.g. split-by-percentage) may temporarily allow values above 100 while the user edits; they can + * opt into this relaxed behavior via the `allowExceedingHundred` flag. */ -function validatePercentage(amount: string): boolean { +function validatePercentage(amount: string, allowExceedingHundred = false): boolean { + if (allowExceedingHundred) { + const digitsOnlyRegex = /^\d*$/u; + return amount === '' || digitsOnlyRegex.test(amount); + } + const regexString = '^(100|[0-9]{1,2})$'; const percentageRegex = new RegExp(regexString, 'i'); return amount === '' || percentageRegex.test(amount); diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 765cbf0508487..0cdeb32a586c2 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -1,5 +1,5 @@ import {deepEqual} from 'fast-equals'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {InteractionManager, Keyboard, View} from 'react-native'; import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; import type {ValueOf} from 'type-fest'; @@ -13,9 +13,7 @@ import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; import SelectionList from '@components/SelectionListWithSections'; import type {SectionListDataType, SplitListItemType} from '@components/SelectionListWithSections/types'; -import getBackgroundColor from '@components/TabSelector/getBackground'; -import getOpacity from '@components/TabSelector/getOpacity'; -import TabSelectorItem from '@components/TabSelector/TabSelectorItem'; +import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; import useDisplayFocusedInputUnderKeyboard from '@hooks/useDisplayFocusedInputUnderKeyboard'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -24,7 +22,6 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; -import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import { addSplitExpenseField, @@ -91,13 +88,8 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [errorMessage, setErrorMessage] = React.useState(''); - const affectedAnimatedTabs = useMemo(() => Array.from({length: tabs.length}, (v, i) => i), []); - const [selectorWidth, setSelectorWidth] = React.useState(0); - const [selectorX, setSelectorX] = React.useState(0); - const tabSelectorViewRef = useRef(null); const [isPercentageMode, setIsPercentageMode] = useState(false); const {currentSearchHash} = useSearchContext(); - const theme = useTheme(); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: false}); const transactionReport = getReportOrDraftReport(draftTransaction?.reportID); @@ -134,16 +126,6 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const splitFieldDataFromChildTransactions = useMemo(() => childTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction)), [childTransactions]); const splitFieldDataFromOriginalTransaction = useMemo(() => initSplitExpenseItemData(transaction), [transaction]); - const measure = useCallback(() => { - tabSelectorViewRef.current?.measureInWindow((x, _y, width) => { - setSelectorX(x); - setSelectorWidth(width); - }); - }, [tabSelectorViewRef]); - - // Measure on mount and when layout changes - useEffect(() => measure(), [measure]); - const {isBetaEnabled} = usePermissions(); useEffect(() => { @@ -421,36 +403,24 @@ function SplitExpensePage({route}: SplitExpensePageProps) { if (!shouldShowMakeSplitsEven) { return; } + + const activeTabKey = isPercentageMode ? CONST.IOU.SPLIT_TYPE.PERCENTAGE : CONST.IOU.SPLIT_TYPE.AMOUNT; + return ( - - {tabs.map((tab, index) => { - const isActive = tab.key === (isPercentageMode ? CONST.IOU.SPLIT_TYPE.PERCENTAGE : CONST.IOU.SPLIT_TYPE.AMOUNT); - const activeOpacity = getOpacity({routesLength: tabs.length, tabIndex: index, active: true, affectedTabs: affectedAnimatedTabs, position: undefined, isActive}); - const inactiveOpacity = getOpacity({routesLength: tabs.length, tabIndex: index, active: false, affectedTabs: affectedAnimatedTabs, position: undefined, isActive}); - const backgroundColor = getBackgroundColor({routesLength: tabs.length, tabIndex: index, affectedTabs: affectedAnimatedTabs, theme, position: undefined, isActive}); - return ( - setIsPercentageMode(tab.key === CONST.IOU.SPLIT_TYPE.PERCENTAGE)} - shouldShowLabelWhenInactive - backgroundColor={backgroundColor} - inactiveOpacity={inactiveOpacity} - activeOpacity={activeOpacity} - parentWidth={selectorWidth} - parentX={selectorX} - /> - ); - })} - + ({ + key: tab.key, + title: translate(tab.titleKey), + icon: tab.key === CONST.IOU.SPLIT_TYPE.AMOUNT ? MoneyCircle : Percent, + testID: tab.testID, + }))} + activeTabKey={activeTabKey} + onTabPress={(key) => setIsPercentageMode(key === CONST.IOU.SPLIT_TYPE.PERCENTAGE)} + shouldShowLabelWhenInactive + equalWidth + /> ); - }, [isPercentageMode, styles, theme, affectedAnimatedTabs, selectorWidth, selectorX, shouldShowMakeSplitsEven, translate, MoneyCircle, Percent]); + }, [MoneyCircle, Percent, isPercentageMode, shouldShowMakeSplitsEven, translate]); const headerTitle = useMemo(() => { if (splitExpenseTransactionID) { diff --git a/src/styles/index.ts b/src/styles/index.ts index ac1b8fd96afb0..254085511dc1a 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1245,7 +1245,6 @@ const staticStyles = (theme: ThemeColors) => optionRowAmountInputContainer: { width: variables.splitExpenseAmountWidth, lineHeight: undefined, - marginLeft: 1, padding: 0, flexDirection: 'row', alignItems: 'center', diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index ab6758a8d44a2..ca7f94184e985 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -187,7 +187,7 @@ describe('IOUUtils', () => { }); describe('calculateSplitPercentagesFromAmounts', () => { - test('Distributes percentages proportionally and adjusts remainder on last item', () => { + test('Distributes percentages proportionally and adjusts remainder across rows while preserving ordering', () => { // 23.00 split as 7.66, 7.66, 7.68 → 3x ~33% but must sum to 100 const totalInCents = 2300; const amounts = [766, 766, 768]; @@ -197,6 +197,18 @@ describe('IOUUtils', () => { expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); }); + test('Ensures larger amounts receive the full remainder so first N are even and the last is highest', () => { + // 2.00 split as 0.33, 0.33, 0.33, 0.33, 0.33, 0.35 + const totalInCents = 200; + const amounts = [33, 33, 33, 33, 33, 35]; + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); + + // The first 5 rows share the same base percentage and the last one gets the full remainder. + // eslint-disable-next-line rulesdir/prefer-at + expect(percentages).toEqual([16, 16, 16, 16, 16, 20]); + expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); + }); + test('Handles zero or empty totals by returning zeros', () => { expect(IOUUtils.calculateSplitPercentagesFromAmounts([], 0)).toEqual([]); expect(IOUUtils.calculateSplitPercentagesFromAmounts([0, 0], 0)).toEqual([0, 0]); diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 8f1df680079a3..8f816102e3b14 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -1,4 +1,4 @@ -import {handleNegativeAmountFlipping, validateAmount} from '@libs/MoneyRequestUtils'; +import {handleNegativeAmountFlipping, validateAmount, validatePercentage} from '@libs/MoneyRequestUtils'; describe('ReportActionsUtils', () => { describe('validateAmount', () => { @@ -20,6 +20,33 @@ describe('ReportActionsUtils', () => { }); }); + describe('validatePercentage', () => { + it('defaults to allowing whole numbers between 0 and 100', () => { + expect(validatePercentage('')).toBe(true); + expect(validatePercentage('0')).toBe(true); + expect(validatePercentage('10')).toBe(true); + expect(validatePercentage('99')).toBe(true); + expect(validatePercentage('100')).toBe(true); + + expect(validatePercentage('150')).toBe(false); + expect(validatePercentage('101')).toBe(false); + }); + + it('allows digit-only values above 100 when allowExceedingHundred is true', () => { + expect(validatePercentage('', true)).toBe(true); + expect(validatePercentage('0', true)).toBe(true); + expect(validatePercentage('100', true)).toBe(true); + expect(validatePercentage('150', true)).toBe(true); + }); + + it('rejects non-numeric characters even when allowExceedingHundred is true', () => { + expect(validatePercentage('1.5', true)).toBe(false); + expect(validatePercentage('abc', true)).toBe(false); + expect(validatePercentage('10%', true)).toBe(false); + expect(validatePercentage('-10', true)).toBe(false); + }); + }); + describe('handleNegativeAmountFlipping', () => { it('should toggle negative and remove dash when allowFlippingAmount is true and amount starts with -', () => { const mockToggleNegative = jest.fn(); From b8cefad9f6bc476426b6dd1659d5ecc2af2a42ad Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 18 Nov 2025 20:41:37 -0800 Subject: [PATCH 12/27] fixed eslint import --- src/components/TabSelector/TabSelectorBase.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 7a42175d71353..8b7b53817b59c 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +import type {Animated} from 'react-native'; import useIsResizing from '@hooks/useIsResizing'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,7 +44,7 @@ type TabSelectorBaseProps = { /** Animated position from a navigator (optional). */ // eslint-disable-next-line no-restricted-imports - position?: import('react-native').Animated.AnimatedInterpolation; + position?: Animated.AnimatedInterpolation; /** Whether to show the label when the tab is inactive. */ shouldShowLabelWhenInactive?: boolean; From 1c774d2e304ca3f8fd9ee5a086cdd721ebfbce6e Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 19 Nov 2025 20:47:49 -0800 Subject: [PATCH 13/27] resolved review comments (4) --- .../SplitExpense/SplitAmountDisplay.tsx | 43 ++++++ .../SplitExpense/SplitAmountInput.tsx | 57 ++++++++ .../SplitExpense/SplitPercentageDisplay.tsx | 28 ++++ .../SplitExpense/SplitPercentageInput.tsx | 55 +++++++ .../SplitListItem.tsx | 135 +++++------------- .../TabSelector/TabSelectorBase.tsx | 2 +- .../BaseTextInput/implementation/index.tsx | 10 +- src/styles/index.ts | 10 -- src/styles/utils/index.ts | 2 - .../splitAmountInputStyles/index.native.ts | 5 - .../utils/splitAmountInputStyles/index.ts | 4 - .../utils/splitAmountInputStyles/types.ts | 6 - src/styles/variables.ts | 1 - 13 files changed, 227 insertions(+), 131 deletions(-) create mode 100644 src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx create mode 100644 src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx create mode 100644 src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx create mode 100644 src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx delete mode 100644 src/styles/utils/splitAmountInputStyles/index.native.ts delete mode 100644 src/styles/utils/splitAmountInputStyles/index.ts delete mode 100644 src/styles/utils/splitAmountInputStyles/types.ts diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx new file mode 100644 index 0000000000000..e70b8ecc201e4 --- /dev/null +++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx @@ -0,0 +1,43 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; +import spacing from '@styles/utils/spacing'; +import CONST from '@src/CONST'; +import {SplitListItemType} from '../types'; + +type SplitAmountDisplayProps = { + splitItem: SplitListItemType; + contentWidth: number; + shouldRemoveSpacing?: boolean; +}; + +function SplitAmountDisplay({splitItem, contentWidth, shouldRemoveSpacing = false}: SplitAmountDisplayProps) { + const styles = useThemeStyles(); + const [prefixCharacterMargin, setPrefixCharacterMargin] = useState(CONST.CHARACTER_WIDTH); + + return ( + + { + if (event.nativeEvent.layout.width === 0 && event.nativeEvent.layout.height === 0) { + return; + } + setPrefixCharacterMargin(event.nativeEvent.layout.width); + }} + > + {splitItem.currencySymbol} + + + {convertToDisplayStringWithoutCurrency(splitItem.amount, splitItem.currency)} + + + ); +} + +export default SplitAmountDisplay; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx new file mode 100644 index 0000000000000..7370ec44d837f --- /dev/null +++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import type {BlurEvent} from 'react-native'; +import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {SplitListItemType} from '../types'; +import SplitAmountDisplay from './SplitAmountDisplay'; + +type SplitAmountInputProps = { + splitItem: SplitListItemType; + formattedOriginalAmount: string; + contentWidth: number; + onSplitExpenseValueChange: (value: string) => void; + focusHandler: () => void; + onInputBlur: ((e: BlurEvent) => void) | undefined; +}; + +function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onSplitExpenseValueChange, focusHandler, onInputBlur}: SplitAmountInputProps) { + const styles = useThemeStyles(); + + if (splitItem.isEditable) { + return ( + + ); + } + return ( + + ); +} + +export default SplitAmountInput; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx new file mode 100644 index 0000000000000..f5b5cefada049 --- /dev/null +++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import {View} from 'react-native'; +import Text from '@components/Text'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; +import {SplitListItemType} from '../types'; + +type SplitPercentageDisplayProps = { + splitItem: SplitListItemType; + contentWidth: number; +}; + +function SplitPercentageDisplay({splitItem, contentWidth}: SplitPercentageDisplayProps) { + const styles = useThemeStyles(); + + return ( + + + {splitItem.percentage}% + + + ); +} + +export default SplitPercentageDisplay; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx new file mode 100644 index 0000000000000..9d68232d00af2 --- /dev/null +++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import type {BlurEvent} from 'react-native'; +import PercentageForm from '@components/PercentageForm'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {SplitListItemType} from '../types'; +import SplitPercentageDisplay from './SplitPercentageDisplay'; + +type SplitAmountInputProps = { + splitItem: SplitListItemType; + contentWidth: number; + percentageDraft: string | undefined; + onSplitExpenseValueChange: (value: string) => void; + setPercentageDraft: React.Dispatch>; + focusHandler: () => void; + onInputBlur: ((e: BlurEvent) => void) | undefined; +}; + +function SplitPercentageInput({splitItem, contentWidth, percentageDraft, onSplitExpenseValueChange, setPercentageDraft, focusHandler, onInputBlur}: SplitAmountInputProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + + const inputValue = percentageDraft ?? String(splitItem.percentage ?? 0); + + if (splitItem.isEditable) { + return ( + { + setPercentageDraft(value); + onSplitExpenseValueChange(value); + }} + value={inputValue} + textInputContainerStyles={StyleUtils.splitPercentageInputStyles(styles)} + containerStyles={styles.optionRowPercentInputContainer} + inputStyle={[styles.optionRowPercentInput, styles.lineHeightUndefined]} + onFocus={focusHandler} + onBlur={(event) => { + setPercentageDraft(undefined); + if (onInputBlur) { + onInputBlur(event); + } + }} + allowExceedingHundred + /> + ); + } + return ( + + ); +} + +export default SplitPercentageInput; diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index e42b769125830..3a97c399c601a 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -1,11 +1,8 @@ -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {View} from 'react-native'; import Icon from '@components/Icon'; -import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput'; -import PercentageForm from '@components/PercentageForm'; import Text from '@components/Text'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; -import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -15,6 +12,9 @@ import {getCommaSeparatedTagNameWithSanitizedColons} from '@libs/PolicyUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; +import SplitAmountDisplay from './SplitExpense/SplitAmountDisplay'; +import SplitAmountInput from './SplitExpense/SplitAmountInput'; +import SplitPercentageInput from './SplitExpense/SplitPercentageInput'; import type {ListItem, SplitListItemProps, SplitListItemType} from './types'; function SplitListItem({ @@ -33,8 +33,6 @@ function SplitListItem({ const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth - const {isSmallScreenWidth} = useResponsiveLayout(); const {Folder, Tag, ArrowRight} = useMemoizedLazyExpensifyIcons(['Folder', 'Tag', 'ArrowRight'] as const); const splitItem = item as unknown as SplitListItemType; @@ -45,7 +43,6 @@ function SplitListItem({ const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0); - const [prefixCharacterMargin, setPrefixCharacterMargin] = useState(CONST.CHARACTER_WIDTH); const contentWidth = (formattedOriginalAmount.length + 1) * CONST.CHARACTER_WIDTH; const [percentageDraft, setPercentageDraft] = useState(); const focusHandler = useCallback(() => { @@ -59,99 +56,7 @@ function SplitListItem({ onInputFocus(index); }, [onInputFocus, index]); - const SplitAmountComponent = useMemo(() => { - if (splitItem.isEditable) { - return ( - - ); - } - return ( - - { - if (event.nativeEvent.layout.width === 0 && event.nativeEvent.layout.height === 0) { - return; - } - setPrefixCharacterMargin(event?.nativeEvent?.layout.width); - }} - > - {splitItem.currencySymbol} - - - {convertToDisplayStringWithoutCurrency(splitItem.amount, splitItem.currency)} - - - ); - }, [ - StyleUtils, - isSmallScreenWidth, - styles, - contentWidth, - prefixCharacterMargin, - splitItem.isEditable, - splitItem.amount, - splitItem.currency, - splitItem.currencySymbol, - formattedOriginalAmount.length, - onSplitExpenseValueChange, - focusHandler, - onInputBlur, - ]); - - const SplitPercentageComponent = useMemo(() => { - const displayedPercentage = percentageDraft ?? String(splitItem.percentage ?? 0); - - if (splitItem.isEditable) { - return ( - { - setPercentageDraft(value); - onSplitExpenseValueChange(value); - }} - value={displayedPercentage} - textInputContainerStyles={StyleUtils.splitPercentageInputStyles(styles)} - containerStyles={styles.optionRowPercentInputContainer} - inputStyle={[styles.optionRowPercentInput, styles.lineHeightUndefined]} - onFocus={focusHandler} - onBlur={(event) => { - setPercentageDraft(undefined); - if (onInputBlur) { - onInputBlur(event); - } - }} - allowExceedingHundred - /> - ); - } - return {`${splitItem.percentage ?? 0}%`}; - }, [StyleUtils, styles, splitItem.isEditable, splitItem.percentage, percentageDraft, onSplitExpenseValueChange, focusHandler, onInputBlur]); + const isPercentageMode = splitItem.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE; return ( ({ > {splitItem.merchant} + {isPercentageMode && ( + + )} @@ -232,7 +144,28 @@ function SplitListItem({ )} - {splitItem.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE ? SplitPercentageComponent : SplitAmountComponent} + + {isPercentageMode ? ( + + ) : ( + + )} + {!splitItem.isEditable ? null : ( diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 8b7b53817b59c..26568127e3b05 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -1,5 +1,6 @@ import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports import type {Animated} from 'react-native'; import useIsResizing from '@hooks/useIsResizing'; import useTheme from '@hooks/useTheme'; @@ -43,7 +44,6 @@ type TabSelectorBaseProps = { onTabPress?: (key: string) => void; /** Animated position from a navigator (optional). */ - // eslint-disable-next-line no-restricted-imports position?: Animated.AnimatedInterpolation; /** Whether to show the label when the tab is inactive. */ diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index 814897266fa16..16989000e814c 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -107,6 +107,7 @@ function BaseTextInput({ const [width, setWidth] = useState(null); const [prefixCharacterPadding, setPrefixCharacterPadding] = useState(CONST.CHARACTER_WIDTH); const [isPrefixCharacterPaddingCalculated, setIsPrefixCharacterPaddingCalculated] = useState(() => !prefixCharacter); + const [isReadyToDisplay, setIsReadyToDisplay] = useState(false); const labelScale = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_SCALE : INACTIVE_LABEL_SCALE); const labelTranslateY = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_TRANSLATE_Y : INACTIVE_LABEL_TRANSLATE_Y); @@ -286,6 +287,7 @@ function BaseTextInput({ // This is workaround for https://github.com/Expensify/App/issues/47939: in case when user is using Chrome on Android we set inputMode to 'search' to disable autocomplete bar above the keyboard. // If we need some other inputMode (eg. 'decimal'), then the autocomplete bar will show, but we can do nothing about it as it's a known Chrome bug. const inputMode = inputProps.inputMode ?? (isMobileChrome() ? 'search' : undefined); + const shouldPreventInputWidthFlicker = !autoGrow && (contentWidth === undefined || (contentWidth >= 0 && isReadyToDisplay)); return ( <> {hasLabel ? ( @@ -529,7 +533,11 @@ function BaseTextInput({ inputPaddingLeft={inputPaddingLeft} autoGrow={autoGrow} isAutoGrowHeightMarkdown={isAutoGrowHeightMarkdown} - onSetTextInputWidth={setTextInputWidth} + onSetTextInputWidth={(width) => { + setTextInputWidth(width); + // Once we have the width measurement, we are ready to display the input to prevent flicker + setIsReadyToDisplay(true); + }} onSetTextInputHeight={setTextInputHeight} isPrefixCharacterPaddingCalculated={isPrefixCharacterPaddingCalculated} /> diff --git a/src/styles/index.ts b/src/styles/index.ts index 254085511dc1a..96f3008c29e97 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1242,16 +1242,6 @@ const staticStyles = (theme: ThemeColors) => textAlign: 'right', }, - optionRowAmountInputContainer: { - width: variables.splitExpenseAmountWidth, - lineHeight: undefined, - padding: 0, - flexDirection: 'row', - alignItems: 'center', - alignSelf: 'center', - textAlign: 'left', - }, - optionRowAmountMobileInputContainer: { width: variables.splitExpenseAmountMobileWidth, }, diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index 097dff5ac20b4..617e6f3b4d703 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -37,7 +37,6 @@ import {compactContentContainerStyles} from './optionRowStyles'; import positioning from './positioning'; import searchHeaderDefaultOffset from './searchHeaderDefaultOffset'; import getSearchPageNarrowHeaderStyles from './searchPageNarrowHeaderStyles'; -import splitAmountInputStyles from './splitAmountInputStyles'; import splitPercentageInputStyles from './splitPercentageInputStyles'; import type { AllStyles, @@ -1335,7 +1334,6 @@ const staticStyleUtils = { getNavigationModalCardStyle, getCardStyles, getSearchPageNarrowHeaderStyles, - splitAmountInputStyles, splitPercentageInputStyles, getOpacityStyle, getMultiGestureCanvasContainerStyle, diff --git a/src/styles/utils/splitAmountInputStyles/index.native.ts b/src/styles/utils/splitAmountInputStyles/index.native.ts deleted file mode 100644 index 3605675c891a9..0000000000000 --- a/src/styles/utils/splitAmountInputStyles/index.native.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type SplitAmountInputStyles from './types'; - -const splitAmountInputStyles: SplitAmountInputStyles = (styles) => [styles.optionRowAmountMobileInputContainer]; - -export default splitAmountInputStyles; diff --git a/src/styles/utils/splitAmountInputStyles/index.ts b/src/styles/utils/splitAmountInputStyles/index.ts deleted file mode 100644 index 90aa05569d7fc..0000000000000 --- a/src/styles/utils/splitAmountInputStyles/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type SplitPercentageInputStyles from './types'; - -const splitPercentageInputStyles: SplitPercentageInputStyles = (styles, isSmallScreenWidth = false) => [isSmallScreenWidth ? styles.optionRowAmountMobileInputContainer : {}]; -export default splitPercentageInputStyles; diff --git a/src/styles/utils/splitAmountInputStyles/types.ts b/src/styles/utils/splitAmountInputStyles/types.ts deleted file mode 100644 index b08018bc939ab..0000000000000 --- a/src/styles/utils/splitAmountInputStyles/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type {ViewStyle} from 'react-native'; -import type {ThemeStyles} from '@styles/index'; - -type SplitAmountInputStyles = (styles: ThemeStyles, isSmallScreenWidth: boolean) => ViewStyle[]; - -export default SplitAmountInputStyles; diff --git a/src/styles/variables.ts b/src/styles/variables.ts index d475086e491db..3f2958c08adcb 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -383,7 +383,6 @@ export default { uberEmptyListIconHeight: 136, // Split expense tabs - splitExpenseAmountWidth: 62, splitExpenseAmountMobileWidth: 82, splitExpensePercentageWidth: 42, splitExpensePercentageMobileWidth: 62, From 3569746ac37f961ecfa403bc8bd84e519f15149b Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 20 Nov 2025 18:13:53 -0800 Subject: [PATCH 14/27] eslint --- .../SplitExpense/SplitAmountDisplay.tsx | 3 +-- src/styles/index.ts | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx index e0c5febd2f793..07356cb1da312 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx @@ -4,7 +4,6 @@ import type {SplitListItemType} from '@components/SelectionListWithSections/type import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; -import spacing from '@styles/utils/spacing'; import CONST from '@src/CONST'; type SplitAmountDisplayProps = { @@ -18,7 +17,7 @@ function SplitAmountDisplay({splitItem, contentWidth, shouldRemoveSpacing = fals const [prefixCharacterMargin, setPrefixCharacterMargin] = useState(CONST.CHARACTER_WIDTH); return ( - + { diff --git a/src/styles/index.ts b/src/styles/index.ts index 32c5075e6f4ee..f4957f2fbb93f 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1231,6 +1231,11 @@ const staticStyles = (theme: ThemeColors) => borderColor: 'transparent', }, + removeSpacing: { + marginVertical: 0, + paddingHorizontal: 0, + }, + outlinedButton: { backgroundColor: 'transparent', borderColor: theme.border, From 99b70e512b3b6a308b36ebe6f84afd2fcdf08316 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 24 Nov 2025 20:47:09 -0800 Subject: [PATCH 15/27] resolved review comments (5) --- .../SplitExpense/SplitAmountDisplay.tsx | 6 ++++-- .../SplitExpense/SplitAmountInput.tsx | 2 ++ .../SplitExpense/SplitPercentageDisplay.tsx | 2 ++ .../SplitExpense/SplitPercentageInput.tsx | 2 ++ .../SelectionListWithSections/SplitListItem.tsx | 1 - src/components/TabSelector/TabSelectorBase.tsx | 7 +------ .../TextInput/BaseTextInput/implementation/index.tsx | 10 +--------- src/pages/iou/SplitExpensePage.tsx | 10 +++++----- src/styles/index.ts | 2 +- 9 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx index 07356cb1da312..96278a92ba72f 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx @@ -8,11 +8,11 @@ import CONST from '@src/CONST'; type SplitAmountDisplayProps = { splitItem: SplitListItemType; - contentWidth: number; + contentWidth?: number | string; shouldRemoveSpacing?: boolean; }; -function SplitAmountDisplay({splitItem, contentWidth, shouldRemoveSpacing = false}: SplitAmountDisplayProps) { +function SplitAmountDisplay({splitItem, contentWidth = '100%', shouldRemoveSpacing = false}: SplitAmountDisplayProps) { const styles = useThemeStyles(); const [prefixCharacterMargin, setPrefixCharacterMargin] = useState(CONST.CHARACTER_WIDTH); @@ -39,4 +39,6 @@ function SplitAmountDisplay({splitItem, contentWidth, shouldRemoveSpacing = fals ); } +SplitAmountDisplay.displayName = 'SplitAmountDisplay'; + export default SplitAmountDisplay; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx index 25ee18ba46b05..ba00e78edf483 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx @@ -57,4 +57,6 @@ function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onS ); } +SplitAmountInput.displayName = 'SplitAmountInput'; + export default SplitAmountInput; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx index b5e90ed746392..b0a5ba8888692 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx @@ -25,4 +25,6 @@ function SplitPercentageDisplay({splitItem, contentWidth}: SplitPercentageDispla ); } +SplitPercentageDisplay.displayName = 'SplitPercentageDisplay'; + export default SplitPercentageDisplay; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx index f00d904d6df26..c40ee498ee6ce 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx @@ -52,4 +52,6 @@ function SplitPercentageInput({splitItem, contentWidth, percentageDraft, onSplit ); } +SplitPercentageInput.displayName = 'SplitPercentageInput'; + export default SplitPercentageInput; diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index 6d67a10c836ff..e34fb85c387fc 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -127,7 +127,6 @@ function SplitListItem({ )} diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 16af98aa397b7..006407fb54fbc 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -153,12 +153,7 @@ function TabSelectorBase({ return; } - if (activeIndex >= 0) { - setAffectedAnimatedTabs([activeIndex, index]); - } else { - setAffectedAnimatedTabs([index]); - } - + setAffectedAnimatedTabs([activeIndex, index]); onTabPress(tab.key); }; diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index 366a1d1c1836a..814897266fa16 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -107,7 +107,6 @@ function BaseTextInput({ const [width, setWidth] = useState(null); const [prefixCharacterPadding, setPrefixCharacterPadding] = useState(CONST.CHARACTER_WIDTH); const [isPrefixCharacterPaddingCalculated, setIsPrefixCharacterPaddingCalculated] = useState(() => !prefixCharacter); - const [isReadyToDisplay, setIsReadyToDisplay] = useState(false); const labelScale = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_SCALE : INACTIVE_LABEL_SCALE); const labelTranslateY = useSharedValue(initialActiveLabel ? ACTIVE_LABEL_TRANSLATE_Y : INACTIVE_LABEL_TRANSLATE_Y); @@ -287,7 +286,6 @@ function BaseTextInput({ // This is workaround for https://github.com/Expensify/App/issues/47939: in case when user is using Chrome on Android we set inputMode to 'search' to disable autocomplete bar above the keyboard. // If we need some other inputMode (eg. 'decimal'), then the autocomplete bar will show, but we can do nothing about it as it's a known Chrome bug. const inputMode = inputProps.inputMode ?? (isMobileChrome() ? 'search' : undefined); - const shouldPreventInputWidthFlicker = !autoGrow && (contentWidth === undefined || (contentWidth >= 0 && isReadyToDisplay)); return ( <> {hasLabel ? ( @@ -533,11 +529,7 @@ function BaseTextInput({ inputPaddingLeft={inputPaddingLeft} autoGrow={autoGrow} isAutoGrowHeightMarkdown={isAutoGrowHeightMarkdown} - onSetTextInputWidth={(inputWidth) => { - setTextInputWidth(inputWidth); - // Once we have the width measurement, we are ready to display the input to prevent flicker - setIsReadyToDisplay(true); - }} + onSetTextInputWidth={setTextInputWidth} onSetTextInputHeight={setTextInputHeight} isPrefixCharacterPaddingCalculated={isPrefixCharacterPaddingCalculated} /> diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index 2c13e453643df..632f530f82b58 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -329,7 +329,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { onSplitExpenseValueChange, ]); - const shouldShowMakeSplitsEven = useMemo(() => childTransactions.length === 0, [childTransactions]); + const isInitialSplit = useMemo(() => childTransactions.length === 0, [childTransactions]); const listFooterContent = useMemo(() => { return ( @@ -340,7 +340,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { icon={expensifyIcons.Plus} style={[styles.ph4]} /> - {shouldShowMakeSplitsEven && ( + {isInitialSplit && ( { // Only show split tab selector if we are creating a split (not editing existing splits) - if (!shouldShowMakeSplitsEven) { + if (!isInitialSplit) { return; } @@ -420,7 +420,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { equalWidth /> ); - }, [expensifyIcons.MoneyCircle, expensifyIcons.Percent, isPercentageMode, shouldShowMakeSplitsEven, translate]); + }, [expensifyIcons.MoneyCircle, expensifyIcons.Percent, isPercentageMode, isInitialSplit, translate]); const headerTitle = useMemo(() => { if (splitExpenseTransactionID) { diff --git a/src/styles/index.ts b/src/styles/index.ts index c735fd8a22f02..3fd4771775e37 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5536,7 +5536,7 @@ const dynamicStyles = (theme: ThemeColors) => paddingBottom: bottomSafeAreaOffset, }), - getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number) => ({ + getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number | string) => ({ marginLeft: inputMarginLeft, width: amountWidth, marginRight: 4, From 532567a4c90844ec806ef4e8ee3f806fb08db97d Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 25 Nov 2025 15:11:41 -0800 Subject: [PATCH 16/27] addressed spacing and flicker --- .../SplitListItem.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index e34fb85c387fc..e09b074379caf 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -114,8 +114,8 @@ function SplitListItem({ {splitItem.headerText} - - + + ({ - {isPercentageMode ? ( - {!splitItem.isEditable ? null : ( From a3e136e9ac94e71af95f88ae02b5ecfce660acf9 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 9 Dec 2025 15:36:33 -0800 Subject: [PATCH 17/27] Revert to Option C, added 0.1% precision logic --- Mobile-Expensify | 2 +- src/components/PercentageForm.tsx | 9 +- .../SplitExpense/SplitPercentageInput.tsx | 1 + src/libs/IOUUtils.ts | 57 ++++----- src/libs/MoneyRequestUtils.ts | 9 +- tests/unit/IOUUtilsTest.ts | 112 ++++++++++++++---- tests/unit/MoneyRequestUtilsTest.ts | 29 +++++ 7 files changed, 157 insertions(+), 62 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index df39af2c57f2e..b28eff0e7a072 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit df39af2c57f2e03b9ab19c50cb8d8436f67df591 +Subproject commit b28eff0e7a0724b3c588fd2c5ba586b2c210da89 diff --git a/src/components/PercentageForm.tsx b/src/components/PercentageForm.tsx index 73cc31e258dcb..5ecab0673bfc9 100644 --- a/src/components/PercentageForm.tsx +++ b/src/components/PercentageForm.tsx @@ -22,11 +22,14 @@ type PercentageFormProps = BaseTextInputProps & { /** Whether to allow values greater than 100 (e.g. split expenses in percentage mode). */ allowExceedingHundred?: boolean; + /** Whether to allow one decimal place (0.1 precision) for more granular percentage splits. */ + allowDecimal?: boolean; + /** Reference to the outer element */ ref?: ForwardedRef; }; -function PercentageForm({value: amount, errorText, onInputChange, label, allowExceedingHundred = false, ref, ...rest}: PercentageFormProps) { +function PercentageForm({value: amount, errorText, onInputChange, label, allowExceedingHundred = false, allowDecimal = false, ref, ...rest}: PercentageFormProps) { const {toLocaleDigit, numberFormat} = useLocalize(); const textInput = useRef(null); @@ -42,14 +45,14 @@ function PercentageForm({value: amount, errorText, onInputChange, label, allowEx // Remove spaces from the newAmount value because Safari on iOS adds spaces when pasting a copied value // More info: https://github.com/Expensify/App/issues/16974 const newAmountWithoutSpaces = stripSpacesFromAmount(newAmount); - if (!validatePercentage(newAmountWithoutSpaces, allowExceedingHundred)) { + if (!validatePercentage(newAmountWithoutSpaces, allowExceedingHundred, allowDecimal)) { return; } const strippedAmount = stripCommaFromAmount(newAmountWithoutSpaces); onInputChange?.(strippedAmount); }, - [allowExceedingHundred, onInputChange], + [allowExceedingHundred, allowDecimal, onInputChange], ); const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit); diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx index c40ee498ee6ce..6937c0cf5c5bd 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx @@ -41,6 +41,7 @@ function SplitPercentageInput({splitItem, contentWidth, percentageDraft, onSplit } }} allowExceedingHundred + allowDecimal /> ); } diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 1671cc7ebbb2c..2bb8907821dcb 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -93,26 +93,24 @@ function calculateAmount(numberOfSplits: number, total: number, currency: string /** * Calculate a split amount in backend cents from a percentage of the original amount. * - Clamps percentage to [0, 100] - * - Rounds percentage to whole numbers (per product spec) + * - Preserves decimal precision in percentage (supports 0.1 precision) * - Uses absolute value of the total amount (cents) */ function calculateSplitAmountFromPercentage(totalInCents: number, percentage: number): number { const totalAbs = Math.abs(totalInCents); - const clamped = Math.min(100, Math.max(0, Math.round(percentage))); + // Clamp percentage to [0, 100] without rounding to preserve decimal precision + const clamped = Math.min(100, Math.max(0, percentage)); return Math.round((totalAbs * clamped) / 100); } /** * Given a list of split amounts (in backend cents) and the original total amount, calculate display percentages * for each split so that: - * - Each row is a whole-number percentage of the original total - * - When the sum of split amounts exactly matches the original total, percentages are proportional to the amounts - * and rounded so that the sum of percentages is exactly 100. Any rounding remainder is allocated entirely to the - * row with the largest amount (breaking ties by using the last such row), so the first N rows share the same - * base percentage and any leftover remainder is applied to the last row. + * - Each row is a percentage of the original total with one decimal place (0.1 precision) + * - Equal amounts ALWAYS have equal percentages + * - The remainder needed to reach 100% goes to the last item (which should be the largest) * - When the sum of split amounts does not match the original total (over/under splits), percentages still reflect - * each amount as a percentage of the original total and may sum to something other than 100; this keeps - * user-entered percentages stable while a validation error highlights the mismatch. + * each amount as a percentage of the original total and may sum to something other than 100 */ function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInCents: number): number[] { const totalAbs = Math.abs(totalInCents); @@ -123,37 +121,33 @@ function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInC const amountsAbs = amountsInCents.map((amount) => Math.abs(amount ?? 0)); - // First compute rounded percentages used when we want to preserve the user's distribution, e.g. when the - // split amounts do not add up to the original total. - const roundedPercentages = amountsAbs.map((amount) => (totalAbs > 0 ? Math.round((amount / totalAbs) * 100) : 0)); - const sumOfRoundedPercentages = roundedPercentages.reduce((sum, current) => sum + current, 0); + // Helper functions for decimal precision + const roundToOneDecimal = (value: number): number => Math.round(value * 10) / 10; + const floorToOneDecimal = (value: number): number => Math.floor(value * 10) / 10; + + // ALWAYS use floored percentages to guarantee equal amounts get equal percentages + const flooredPercentages = amountsAbs.map((amount) => (totalAbs > 0 ? floorToOneDecimal((amount / totalAbs) * 100) : 0)); + const amountsTotal = amountsAbs.reduce((sum, curr) => sum + curr, 0); - // If the split amounts don't add up to the original total, return rounded percentages as-is so user-entered - // percentages and amounts remain stable while a validation error highlights the mismatch. + // If the split amounts don't add up to the original total, return floored percentages as-is + // (the sum may not be 100, but that's expected when there's a validation error) if (amountsTotal !== totalAbs) { - return roundedPercentages; - } - - // If rounded percentages already sum to 100, we can also return them directly. - if (sumOfRoundedPercentages === 100) { - return roundedPercentages; + return flooredPercentages; } - // Otherwise, compute base percentages by flooring the exact percentages and then allocating the full remainder - // to the row with the largest amount (last one in case of ties). This ensures the first N rows share the same - // base percentage and any leftover is applied entirely to the last row, matching how we treat remainder in - // split amounts. - const flooredPercentages = amountsAbs.map((amount) => (totalAbs > 0 ? Math.floor((amount / totalAbs) * 100) : 0)); - const sumOfFlooredPercentages = flooredPercentages.reduce((sum, current) => sum + current, 0); - const remainder = 100 - sumOfFlooredPercentages; + // Calculate remainder and add it to the LAST item (which should be the largest in even splits) + const sumOfFlooredPercentages = roundToOneDecimal(flooredPercentages.reduce((sum, current) => sum + current, 0)); + const remainder = roundToOneDecimal(100 - sumOfFlooredPercentages); - if (remainder === 0) { + if (remainder <= 0) { return flooredPercentages; } + // Add remainder to the last item with the MAXIMUM amount (not just the last item since that can be a new split with 0 amount) + // This ensures 0-amount splits stay at 0% const maxAmount = Math.max(...amountsAbs); - let lastMaxIndex = 0; + let lastMaxIndex = amountsAbs.length - 1; // fallback to last for (let i = 0; i < amountsAbs.length; i += 1) { if (amountsAbs.at(i) === maxAmount) { lastMaxIndex = i; @@ -161,8 +155,7 @@ function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInC } const adjustedPercentages = [...flooredPercentages]; - const baseValue = adjustedPercentages.at(lastMaxIndex) ?? 0; - adjustedPercentages[lastMaxIndex] = Math.max(0, baseValue + remainder); + adjustedPercentages[lastMaxIndex] = roundToOneDecimal((adjustedPercentages.at(lastMaxIndex) ?? 0) + remainder); return adjustedPercentages; } diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts index 18db07a30199c..da7bcb6a0ad05 100644 --- a/src/libs/MoneyRequestUtils.ts +++ b/src/libs/MoneyRequestUtils.ts @@ -59,14 +59,15 @@ function validateAmount(amount: string, decimals: number, amountMaxLength: numbe * By default we keep backwards-compatible behavior and only allow whole-number percentages between 0 and 100. * Some callers (e.g. split-by-percentage) may temporarily allow values above 100 while the user edits; they can * opt into this relaxed behavior via the `allowExceedingHundred` flag. + * The `allowDecimal` flag enables one decimal place (0.1 precision) for more granular percentage splits. */ -function validatePercentage(amount: string, allowExceedingHundred = false): boolean { +function validatePercentage(amount: string, allowExceedingHundred = false, allowDecimal = false): boolean { if (allowExceedingHundred) { - const digitsOnlyRegex = /^\d*$/u; - return amount === '' || digitsOnlyRegex.test(amount); + const regex = allowDecimal ? /^\d*\.?\d?$/u : /^\d*$/u; + return amount === '' || regex.test(amount); } - const regexString = '^(100|[0-9]{1,2})$'; + const regexString = allowDecimal ? '^(100(\\.0)?|[0-9]{1,2}(\\.\\d)?)$' : '^(100|[0-9]{1,2})$'; const percentageRegex = new RegExp(regexString, 'i'); return amount === '' || percentageRegex.test(amount); } diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 93706070bf0fd..a4d64034e77b9 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -176,7 +176,13 @@ describe('IOUUtils', () => { describe('calculateSplitAmountFromPercentage', () => { test('Basic percentage calculation and rounding', () => { expect(IOUUtils.calculateSplitAmountFromPercentage(20000, 25)).toBe(5000); - expect(IOUUtils.calculateSplitAmountFromPercentage(199, 50)).toBe(100); // rounds + expect(IOUUtils.calculateSplitAmountFromPercentage(199, 50)).toBe(100); + }); + + test('Handles decimal percentages', () => { + expect(IOUUtils.calculateSplitAmountFromPercentage(10000, 7.7)).toBe(770); + expect(IOUUtils.calculateSplitAmountFromPercentage(10000, 33.3)).toBe(3330); + expect(IOUUtils.calculateSplitAmountFromPercentage(8900, 7.7)).toBe(685); }); test('Clamps percentage between 0 and 100 and uses absolute total', () => { @@ -187,26 +193,46 @@ describe('IOUUtils', () => { }); describe('calculateSplitPercentagesFromAmounts', () => { - test('Distributes percentages proportionally and adjusts remainder across rows while preserving ordering', () => { - // 23.00 split as 7.66, 7.66, 7.68 → 3x ~33% but must sum to 100 - const totalInCents = 2300; - const amounts = [766, 766, 768]; - const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); + test('Equal amounts always have equal percentages', () => { + // All equal amounts should get equal floored percentages + const amounts = [33, 33, 35]; + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, 101); + + // First two (equal amounts) should have equal percentages + expect(percentages.at(0)).toBe(percentages.at(1)); + // Last one (larger) should have the remainder + expect(percentages.at(2)).toBeGreaterThan(percentages.at(0) ?? 0); + // Sum should be 100 + expect(Math.round(percentages.reduce((sum, p) => sum + p, 0) * 10) / 10).toBe(100); + }); - expect(percentages).toEqual([33, 33, 34]); - expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); + test('Zero-amount splits stay at 0 percent', () => { + // Splits with 0 amount should have 0% even when there is a remainder + const amounts = [33, 33, 35, 0, 0]; + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, 101); + + // Zero amounts should be 0% + expect(percentages.at(3)).toBe(0); + expect(percentages.at(4)).toBe(0); + // First two (equal amounts) should have equal percentages + expect(percentages.at(0)).toBe(percentages.at(1)); + // Sum should be 100 + expect(Math.round(percentages.reduce((sum, p) => sum + p, 0) * 10) / 10).toBe(100); }); - test('Ensures larger amounts receive the full remainder so first N are even and the last is highest', () => { - // 2.00 split as 0.33, 0.33, 0.33, 0.33, 0.33, 0.35 - const totalInCents = 200; - const amounts = [33, 33, 33, 33, 33, 35]; + test('Returns percentages with one decimal place', () => { + const totalInCents = 2300; + const amounts = [766, 766, 768]; const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); - // The first 5 rows share the same base percentage and the last one gets the full remainder. - // eslint-disable-next-line rulesdir/prefer-at - expect(percentages).toEqual([16, 16, 16, 16, 16, 20]); - expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); + // First two equal amounts should have equal percentages + expect(percentages.at(0)).toBe(percentages.at(1)); + // Percentages should have at most one decimal place + for (const p of percentages) { + expect(Math.round(p * 10) / 10).toBe(p); + } + // Sum should be 100 + expect(Math.round(percentages.reduce((sum, p) => sum + p, 0) * 10) / 10).toBe(100); }); test('Handles zero or empty totals by returning zeros', () => { @@ -219,20 +245,62 @@ describe('IOUUtils', () => { const amounts = [-766, -766, -768]; const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); - expect(percentages).toEqual([33, 33, 34]); - expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(100); + // Equal amounts should have equal percentages + expect(percentages.at(0)).toBe(percentages.at(1)); + // Sum should be 100 + expect(Math.round(percentages.reduce((sum, p) => sum + p, 0) * 10) / 10).toBe(100); }); - test('Keeps raw percentages when split totals differ from original total', () => { + test('Returns floored percentages when split totals differ from original total', () => { const originalTotalInCents = 20000; const amounts = [10000, 10000, 5000]; // totals 25000, larger than original total const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, originalTotalInCents); - // Each amount is expressed as a percentage of the original total - expect(percentages).toEqual([50, 50, 25]); - // The sum can exceed 100 when splits are over the original total; the validation error covers this + // Each amount is expressed as floored percentage of the original total + expect(percentages.at(0)).toBe(percentages.at(1)); // Equal amounts have equal percentages + // The sum can exceed 100 when splits are over the original total expect(percentages.reduce((sum, current) => sum + current, 0)).toBe(125); }); + + test('Produces normalized percentages for 13-way split of $89', () => { + const totalInCents = 8900; + const amounts = [684, 684, 684, 684, 684, 685, 685, 685, 685, 685, 685, 685, 685]; + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); + + // All 684s (first 5) should have equal percentages + const first5 = percentages.slice(0, 5); + expect(new Set(first5).size).toBe(1); + + // All 685s except the last should have equal percentages + const middle7 = percentages.slice(5, 12); + expect(new Set(middle7).size).toBe(1); + + // Base percentage should be 7.6 (floored from 7.68-7.69) + expect(first5.at(0)).toBe(7.6); + + // Sum should be 100 + expect(Math.round(percentages.reduce((sum, p) => sum + p, 0) * 10) / 10).toBe(100); + }); + + test('Produces normalized percentages for 12-way split of $22', () => { + const totalInCents = 2200; + const amounts = [183, 183, 183, 183, 183, 183, 183, 183, 184, 184, 184, 184]; + const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents); + + // All 183s (first 8) should have equal percentages + const first8 = percentages.slice(0, 8); + expect(new Set(first8).size).toBe(1); + + // All 184s except the last should have equal percentages + const middle3 = percentages.slice(8, 11); + expect(new Set(middle3).size).toBe(1); + + // Base percentage should be 8.3 (floored from 8.31-8.36) + expect(first8.at(0)).toBe(8.3); + + // Sum should be 100 + expect(Math.round(percentages.reduce((sum, p) => sum + p, 0) * 10) / 10).toBe(100); + }); }); describe('insertTagIntoTransactionTagsString', () => { diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts index 8ff285af894e2..7a9e7cb9399ad 100644 --- a/tests/unit/MoneyRequestUtilsTest.ts +++ b/tests/unit/MoneyRequestUtilsTest.ts @@ -48,6 +48,35 @@ describe('ReportActionsUtils', () => { expect(validatePercentage('10%', true)).toBe(false); expect(validatePercentage('-10', true)).toBe(false); }); + + it('allows one decimal place when allowDecimal is true', () => { + // Valid decimal percentages + expect(validatePercentage('7.5', false, true)).toBe(true); + expect(validatePercentage('0.1', false, true)).toBe(true); + expect(validatePercentage('99.9', false, true)).toBe(true); + expect(validatePercentage('100.0', false, true)).toBe(true); + expect(validatePercentage('50', false, true)).toBe(true); + expect(validatePercentage('', false, true)).toBe(true); + + // Invalid: more than one decimal place + expect(validatePercentage('7.55', false, true)).toBe(false); + expect(validatePercentage('100.01', false, true)).toBe(false); + + // Invalid: over 100 + expect(validatePercentage('100.1', false, true)).toBe(false); + expect(validatePercentage('150', false, true)).toBe(false); + }); + + it('allows decimals and exceeding 100 when both flags are true', () => { + expect(validatePercentage('150.5', true, true)).toBe(true); + expect(validatePercentage('7.5', true, true)).toBe(true); + expect(validatePercentage('200', true, true)).toBe(true); + expect(validatePercentage('.5', true, true)).toBe(true); + + // Invalid: more than one decimal place + expect(validatePercentage('7.55', true, true)).toBe(false); + expect(validatePercentage('abc', true, true)).toBe(false); + }); }); describe('handleNegativeAmountFlipping', () => { From b1540788e5d8db9a12c75dadc9cb15d066aca498 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 9 Dec 2025 20:27:14 -0800 Subject: [PATCH 18/27] resolved submodule --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index b28eff0e7a072..83624a7d93247 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit b28eff0e7a0724b3c588fd2c5ba586b2c210da89 +Subproject commit 83624a7d93247e96d0f7bec2e159ec942d0a2b5b From 905a9c6669d91cb453ce51da0ec8886bb921004f Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Tue, 9 Dec 2025 20:28:12 -0800 Subject: [PATCH 19/27] resolved submodule (1) --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 83624a7d93247..74cf45283eb6a 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 83624a7d93247e96d0f7bec2e159ec942d0a2b5b +Subproject commit 74cf45283eb6a3155ed18ef72526963bd15bc844 From 1c7503b57678e816f8b0d0c208258575c250f591 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 10 Dec 2025 15:20:26 -0800 Subject: [PATCH 20/27] added JSDoc comments to new components --- .../SplitExpense/SplitAmountDisplay.tsx | 3 +++ .../SplitExpense/SplitAmountInput.tsx | 7 +++++++ .../SplitExpense/SplitPercentageDisplay.tsx | 2 ++ .../SplitExpense/SplitPercentageInput.tsx | 11 +++++++++-- 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx index 96278a92ba72f..1035934ab507f 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx @@ -7,8 +7,11 @@ import {convertToDisplayStringWithoutCurrency} from '@libs/CurrencyUtils'; import CONST from '@src/CONST'; type SplitAmountDisplayProps = { + /** The split item data containing amount, currency, and editable state. */ splitItem: SplitListItemType; + /** The width of the content area. */ contentWidth?: number | string; + /** Whether to remove default spacing from the container. */ shouldRemoveSpacing?: boolean; }; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx index 632f88406f62d..73a9ef73308ee 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx @@ -7,12 +7,19 @@ import useThemeStyles from '@hooks/useThemeStyles'; import SplitAmountDisplay from './SplitAmountDisplay'; type SplitAmountInputProps = { + /** The split item data containing amount, currency, and editable state. */ splitItem: SplitListItemType; + /** The formatted original amount string used to calculate max input length. */ formattedOriginalAmount: string; + /** The width of the input content area. */ contentWidth: number; + /** Callback invoked when the split expense value changes. */ onSplitExpenseValueChange: (value: string) => void; + /** Callback invoked when the input receives focus. */ focusHandler: () => void; + /** Callback invoked when the input loses focus. */ onInputBlur: ((e: BlurEvent) => void) | undefined; + /** Callback ref for accessing the underlying text input. */ inputCallbackRef: (ref: BaseTextInputRef | null) => void; }; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx index b0a5ba8888692..cbd05b5ba34b2 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx @@ -6,7 +6,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import CONST from '@src/CONST'; type SplitPercentageDisplayProps = { + /** The split item data containing amount, currency, and editable state. */ splitItem: SplitListItemType; + /** The width of the content area. */ contentWidth: number; }; diff --git a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx index 6937c0cf5c5bd..397e34337c17a 100644 --- a/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx +++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx @@ -6,17 +6,24 @@ import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import SplitPercentageDisplay from './SplitPercentageDisplay'; -type SplitAmountInputProps = { +type SplitPercentageInputProps = { + /** The split item data containing amount, currency, and editable state. */ splitItem: SplitListItemType; + /** The width of the input content area. */ contentWidth: number; + /** The draft percentage value while the user is editing. */ percentageDraft: string | undefined; + /** Callback invoked when the split expense value changes. */ onSplitExpenseValueChange: (value: string) => void; + /** State setter for the percentage draft value. */ setPercentageDraft: React.Dispatch>; + /** Callback invoked when the input receives focus. */ focusHandler: () => void; + /** Callback invoked when the input loses focus. */ onInputBlur: ((e: BlurEvent) => void) | undefined; }; -function SplitPercentageInput({splitItem, contentWidth, percentageDraft, onSplitExpenseValueChange, setPercentageDraft, focusHandler, onInputBlur}: SplitAmountInputProps) { +function SplitPercentageInput({splitItem, contentWidth, percentageDraft, onSplitExpenseValueChange, setPercentageDraft, focusHandler, onInputBlur}: SplitPercentageInputProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); From 5bd0d3f9ed9e1f35148c0f20d8a4e11317b8876f Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Thu, 11 Dec 2025 19:09:56 -0800 Subject: [PATCH 21/27] resolve submodule --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index ad51a4ddf1528..fa7d3d8b6017a 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit ad51a4ddf1528fe65f17387bc3a996ebc650ccf2 +Subproject commit fa7d3d8b6017a37bb08f5224312687dfea976a64 From 94cb4d6794910cd1764e9b4d98d56564206e54ed Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 12 Dec 2025 19:21:51 -0800 Subject: [PATCH 22/27] addressed focus scrolling inconsistencies --- .../BaseSelectionListWithSections.tsx | 46 +++++++++++++++++-- src/styles/variables.ts | 2 + 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index a179a0dae1ad0..1e83f1c7545ef 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -190,15 +190,31 @@ function BaseSelectionListWithSections({ const itemHeights = useRef>({}); const pendingScrollIndexRef = useRef(null); - const onItemLayout = (event: LayoutChangeEvent, itemKey: string | null | undefined) => { - if (!itemKey) { + /** + * Gets a cache key for an item's height, including mode for split items + * This ensures amount and percentage modes have separate cached heights + */ + const getHeightCacheKey = (item: TItem): string | null => { + if (!item?.keyForList) { + return null; + } + // For split items with mode, include mode in cache key + if ('mode' in item) { + return `${item.keyForList}_${(item as {mode: string}).mode}`; + } + return item.keyForList; + }; + + const onItemLayout = (event: LayoutChangeEvent, item: TItem) => { + const cacheKey = getHeightCacheKey(item); + if (!cacheKey) { return; } const {height} = event.nativeEvent.layout; itemHeights.current = { ...itemHeights.current, - [itemKey]: height, + [cacheKey]: height, }; }; @@ -253,7 +269,21 @@ function BaseSelectionListWithSections({ disabledIndex += 1; // Account for the height of the item in getItemLayout - const fullItemHeight = item?.keyForList && itemHeights.current[item.keyForList] ? itemHeights.current[item.keyForList] : getItemHeight(item); + // Use mode-aware cache key for split items to prevent stale heights when switching modes + const cacheKey = getHeightCacheKey(item); + let fullItemHeight: number; + if (cacheKey && itemHeights.current[cacheKey]) { + // Use cached height if available + fullItemHeight = itemHeights.current[cacheKey]; + } else if ('mode' in item && item.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE) { + // For percentage mode items without cached height, use the known height as + // this prevents incorrect scroll calculations on first mount + fullItemHeight = variables.splitExpensePercentageCardHeight; + } else { + // Default fallback + fullItemHeight = getItemHeight(item); + } + itemLayouts.push({length: fullItemHeight, offset}); offset += fullItemHeight; @@ -354,6 +384,12 @@ function BaseSelectionListWithSections({ viewOffsetToKeepFocusedItemAtTopOfViewableArea = firstPreviousItemHeight + secondPreviousItemHeight; } + // Check if the focused item input is percentage mode (taller card needs more offset) + // the offset is proportional to the item's position since each percentage card is taller than amount card + // the cumulative height difference grows with each item above the focused one. + const isPercentageMode = 'mode' in item && item.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE; + viewOffsetToKeepFocusedItemAtTopOfViewableArea += isPercentageMode ? (index + 1) * variables.splitExpensePercentageScrollOffset : 0; + listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight - viewOffsetToKeepFocusedItemAtTopOfViewableArea}); pendingScrollIndexRef.current = null; }, @@ -655,7 +691,7 @@ function BaseSelectionListWithSections({ return ( onItemLayout(event, item?.keyForList)} + onLayout={(event: LayoutChangeEvent) => onItemLayout(event, item)} onMouseMove={() => setCurrentHoverIndex(normalizedIndex)} onMouseEnter={() => setCurrentHoverIndex(normalizedIndex)} onMouseLeave={(e) => { diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 7b5030e750cba..9a6baaaed24a3 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -391,4 +391,6 @@ export default { splitExpenseAmountMobileWidth: 82, splitExpensePercentageWidth: 42, splitExpensePercentageMobileWidth: 62, + splitExpensePercentageScrollOffset: 12, + splitExpensePercentageCardHeight: 126, } as const; From cee1a110f5e30fdceb511aad49f98bd9f4454ae4 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Fri, 12 Dec 2025 19:22:51 -0800 Subject: [PATCH 23/27] resolved submodule --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index fa7d3d8b6017a..4d136fc5e3d0b 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit fa7d3d8b6017a37bb08f5224312687dfea976a64 +Subproject commit 4d136fc5e3d0bdd58fb436a47faec520e25cb426 From 6bc954087ea846e97d9dc8ed06b2498c850f3115 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Mon, 15 Dec 2025 19:39:12 -0800 Subject: [PATCH 24/27] onyx tab navigator refactoring --- Mobile-Expensify | 2 +- src/CONST/index.ts | 1 + src/ONYXKEYS.ts | 4 + .../BaseSelectionListWithSections.tsx | 8 +- .../SplitListItem.tsx | 39 ++-- .../SplitListItemInput.tsx | 71 +++++++ .../SelectionListWithSections/index.tsx | 3 + .../SelectionListWithSections/types.ts | 3 + src/components/TabSelector/TabSelector.tsx | 8 +- src/libs/Navigation/OnyxTabNavigator.tsx | 4 +- src/pages/iou/SplitAmountList.tsx | 67 +++++++ src/pages/iou/SplitExpensePage.tsx | 173 ++++++++---------- src/pages/iou/SplitPercentageList.tsx | 68 +++++++ src/types/onyx/SplitSelectedTabRequest.ts | 7 + src/types/onyx/index.ts | 2 + 15 files changed, 332 insertions(+), 128 deletions(-) create mode 100644 src/components/SelectionListWithSections/SplitListItemInput.tsx create mode 100644 src/pages/iou/SplitAmountList.tsx create mode 100644 src/pages/iou/SplitPercentageList.tsx create mode 100644 src/types/onyx/SplitSelectedTabRequest.ts diff --git a/Mobile-Expensify b/Mobile-Expensify index 4d136fc5e3d0b..e69fbe35691ec 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 4d136fc5e3d0bdd58fb436a47faec520e25cb426 +Subproject commit e69fbe35691ec1f648ffb1cb2efb1358a7c93576 diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 9b2adb4764c6a..3eb19310523fb 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -5455,6 +5455,7 @@ const CONST = { RECEIPT_TAB_ID: 'ReceiptTab', IOU_REQUEST_TYPE: 'iouRequestType', DISTANCE_REQUEST_TYPE: 'distanceRequestType', + SPLIT_EXPENSE_TAB_TYPE: 'splitExpenseTabType', SHARE: { NAVIGATOR_ID: 'ShareNavigatorID', SHARE: 'ShareTab', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 2a7e41df0b042..4c4c4d54373d4 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -681,6 +681,9 @@ const ONYXKEYS = { // Manual expense tab selector SELECTED_DISTANCE_REQUEST_TAB: 'selectedDistanceRequestTab_', + // IOU request split tab selector + SPLIT_SELECTED_TAB: 'splitSelectedTab_', + /** This is deprecated, but needed for a migration, so we still need to include it here so that it will be initialized in Onyx.init */ DEPRECATED_POLICY_MEMBER_LIST: 'policyMemberList_', @@ -1103,6 +1106,7 @@ type OnyxCollectionValuesMapping = { [ONYXKEYS.COLLECTION.POLICY_RECENTLY_USED_TAGS]: OnyxTypes.RecentlyUsedTags; [ONYXKEYS.COLLECTION.SELECTED_TAB]: OnyxTypes.SelectedTabRequest; [ONYXKEYS.COLLECTION.SELECTED_DISTANCE_REQUEST_TAB]: OnyxTypes.SelectedTabRequest; + [ONYXKEYS.COLLECTION.SPLIT_SELECTED_TAB]: OnyxTypes.SplitSelectedTabRequest; [ONYXKEYS.COLLECTION.PRIVATE_NOTES_DRAFT]: string; [ONYXKEYS.COLLECTION.NVP_EXPENSIFY_REPORT_PDF_FILENAME]: string; [ONYXKEYS.COLLECTION.NEXT_STEP]: OnyxTypes.ReportNextStepDeprecated; diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx index a179a0dae1ad0..78b129c0e4b32 100644 --- a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx +++ b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx @@ -144,6 +144,7 @@ function BaseSelectionListWithSections({ shouldHighlightSelectedItem = true, shouldDisableHoverStyle = false, setShouldDisableHoverStyle = () => {}, + isPercentageMode, ref, }: SelectionListProps) { const styles = useThemeStyles(); @@ -354,7 +355,12 @@ function BaseSelectionListWithSections({ viewOffsetToKeepFocusedItemAtTopOfViewableArea = firstPreviousItemHeight + secondPreviousItemHeight; } - listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset: variables.contentHeaderHeight - viewOffsetToKeepFocusedItemAtTopOfViewableArea}); + let viewOffset = variables.contentHeaderHeight - viewOffsetToKeepFocusedItemAtTopOfViewableArea; + // Remove contentHeaderHeight from viewOffset calculation if isPercentageMode (for scroll offset calculation on native) + if (isPercentageMode) { + viewOffset = viewOffsetToKeepFocusedItemAtTopOfViewableArea; + } + listRef.current.scrollToLocation({sectionIndex, itemIndex, animated, viewOffset}); pendingScrollIndexRef.current = null; }, diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx index e09b074379caf..a41407836e557 100644 --- a/src/components/SelectionListWithSections/SplitListItem.tsx +++ b/src/components/SelectionListWithSections/SplitListItem.tsx @@ -15,8 +15,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import BaseListItem from './BaseListItem'; import SplitAmountDisplay from './SplitExpense/SplitAmountDisplay'; -import SplitAmountInput from './SplitExpense/SplitAmountInput'; -import SplitPercentageInput from './SplitExpense/SplitPercentageInput'; +import SplitListItemInput from './SplitListItemInput'; import type {ListItem, SplitListItemProps, SplitListItemType} from './types'; function SplitListItem({ @@ -171,30 +170,18 @@ function SplitListItem({ - {/* Amount input is always mounted, just hidden in percentage mode to avoid layout flicker */} - - - - {/* Percentage input can stay conditional since it doesn't calculate width at mount */} - - - + {!splitItem.isEditable ? null : ( diff --git a/src/components/SelectionListWithSections/SplitListItemInput.tsx b/src/components/SelectionListWithSections/SplitListItemInput.tsx new file mode 100644 index 0000000000000..edeb303f2f2a6 --- /dev/null +++ b/src/components/SelectionListWithSections/SplitListItemInput.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import type {BlurEvent} from 'react-native'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; +import SplitAmountInput from './SplitExpense/SplitAmountInput'; +import SplitPercentageInput from './SplitExpense/SplitPercentageInput'; +import type {SplitListItemType} from './types'; + +type SplitListItemInputProps = { + /** Whether the list is percentage mode (for scroll offset calculation) */ + isPercentageMode: boolean; + /** The split item data containing amount, currency, and editable state. */ + splitItem: SplitListItemType; + /** The width of the input content area. */ + contentWidth: number; + /** The formatted original amount string used to calculate max input length. */ + formattedOriginalAmount: string; + /** The draft percentage value while the user is editing. */ + percentageDraft?: string; + /** Callback invoked when the split expense value changes. */ + onSplitExpenseValueChange: (value: string) => void; + /** State setter for the percentage draft value. */ + setPercentageDraft: React.Dispatch>; + /** Callback invoked when the input receives focus. */ + focusHandler: () => void; + /** Callback invoked when the input loses focus. */ + onInputBlur: ((e: BlurEvent) => void) | undefined; + /** Callback ref for accessing the underlying text input. */ + inputCallbackRef: (ref: BaseTextInputRef | null) => void; +}; + +function SplitListItemInput({ + isPercentageMode, + splitItem, + contentWidth, + formattedOriginalAmount, + percentageDraft, + onSplitExpenseValueChange, + setPercentageDraft, + focusHandler, + onInputBlur, + inputCallbackRef, +}: SplitListItemInputProps) { + if (isPercentageMode) { + return ( + + ); + } + return ( + + ); +} + +SplitListItemInput.displayName = 'SplitListItemInput'; + +export default SplitListItemInput; diff --git a/src/components/SelectionListWithSections/index.tsx b/src/components/SelectionListWithSections/index.tsx index 258152bec1070..4a86f47fc81e1 100644 --- a/src/components/SelectionListWithSections/index.tsx +++ b/src/components/SelectionListWithSections/index.tsx @@ -107,6 +107,9 @@ function SelectionListWithSections({onScroll, shouldHide isRowMultilineSupported shouldDisableHoverStyle={shouldDisableHoverStyle} setShouldDisableHoverStyle={setShouldDisableHoverStyle} + // We only allow the prop to pass on Native (for scroll offset calculation) + // web should be false by default since there are no issues on web + isPercentageMode={false} /> ); } diff --git a/src/components/SelectionListWithSections/types.ts b/src/components/SelectionListWithSections/types.ts index bf61c91cef4f5..3f6824bbd551e 100644 --- a/src/components/SelectionListWithSections/types.ts +++ b/src/components/SelectionListWithSections/types.ts @@ -1008,6 +1008,9 @@ type SelectionListProps = Partial & { /** Whether hover style should be disabled */ shouldDisableHoverStyle?: boolean; setShouldDisableHoverStyle?: React.Dispatch>; + + /** Whether the list is percentage mode (for scroll offset calculation) */ + isPercentageMode?: boolean; } & TRightHandSideComponent; type SelectionListHandle = { diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index e3b2e047dacd7..4a5c5b2b59c67 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -37,7 +37,7 @@ type IconTitleAndTestID = { }; function getIconTitleAndTestID( - icons: Record<'CalendarSolid' | 'UploadAlt' | 'User' | 'Car' | 'Hashtag' | 'Map' | 'Pencil' | 'ReceiptScan' | 'Receipt', IconAsset>, + icons: Record<'CalendarSolid' | 'UploadAlt' | 'User' | 'Car' | 'Hashtag' | 'Map' | 'Pencil' | 'ReceiptScan' | 'Receipt' | 'MoneyCircle' | 'Percent', IconAsset>, route: string, translate: LocaleContextProps['translate'], ): IconTitleAndTestID { @@ -68,6 +68,10 @@ function getIconTitleAndTestID( return {icon: icons.Map, title: translate('tabSelector.map'), testID: 'distanceMap'}; case CONST.TAB_REQUEST.DISTANCE_MANUAL: return {icon: icons.Pencil, title: translate('tabSelector.manual'), testID: 'distanceManual'}; + case CONST.IOU.SPLIT_TYPE.AMOUNT: + return {icon: icons.MoneyCircle, title: translate('iou.amount'), testID: 'split-amount'}; + case CONST.IOU.SPLIT_TYPE.PERCENTAGE: + return {icon: icons.Percent, title: translate('iou.percent'), testID: 'split-percentage'}; default: throw new Error(`Route ${route} has no icon nor title set.`); } @@ -84,7 +88,7 @@ function TabSelector({ renderProductTrainingTooltip, equalWidth = false, }: TabSelectorProps) { - const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'UploadAlt', 'User', 'Pencil', 'ReceiptScan', 'Hashtag', 'Car', 'Receipt', 'Map'] as const); + const icons = useMemoizedLazyExpensifyIcons(['CalendarSolid', 'UploadAlt', 'User', 'Pencil', 'ReceiptScan', 'Hashtag', 'Car', 'Receipt', 'Map', 'MoneyCircle', 'Percent'] as const); const {translate} = useLocalize(); const tabs: TabSelectorBaseItem[] = useMemo( diff --git a/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx index 20ed35077893f..9e9cbd547b65f 100644 --- a/src/libs/Navigation/OnyxTabNavigator.tsx +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -11,7 +11,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import type {IOURequestType} from '@libs/actions/IOU'; import Tab from '@userActions/Tab'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {SelectedTabRequest} from '@src/types/onyx'; +import type {SelectedTabRequest, SplitSelectedTabRequest} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; import {defaultScreenOptions} from './OnyxTabNavigatorConfig'; @@ -21,7 +21,7 @@ type OnyxTabNavigatorProps = ChildrenProps & { id: string; /** Name of the selected tab */ - defaultSelectedTab?: SelectedTabRequest; + defaultSelectedTab?: SelectedTabRequest | SplitSelectedTabRequest; /** A function triggered when a tab has been selected */ onTabSelected?: (newIouType: IOURequestType) => void; diff --git a/src/pages/iou/SplitAmountList.tsx b/src/pages/iou/SplitAmountList.tsx new file mode 100644 index 0000000000000..1c6e851a821d2 --- /dev/null +++ b/src/pages/iou/SplitAmountList.tsx @@ -0,0 +1,67 @@ +import React, {useMemo} from 'react'; +import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; +import SelectionList from '@components/SelectionListWithSections'; +import type {SectionListDataType, SplitListItemType} from '@components/SelectionListWithSections/types'; +import useDisplayFocusedInputUnderKeyboard from '@hooks/useDisplayFocusedInputUnderKeyboard'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; + +type SplitAmountListProps = { + /** The split expense sections data. */ + sections: Array>; + /** The initially focused option key. */ + initiallyFocusedOptionKey: string | undefined; + /** Callback when a row is selected. */ + onSelectRow: (item: SplitListItemType) => void; + /** Footer content to render at the bottom of the list. */ + listFooterContent?: React.JSX.Element | null; +}; + +/** + * Dedicated component for the Amount tab in Split Expense flow. + * Renders split items with amount inputs, managing its own scroll/height state. + */ +function SplitAmountList({sections, initiallyFocusedOptionKey, onSelectRow, listFooterContent}: SplitAmountListProps) { + const styles = useThemeStyles(); + const {listRef, bottomOffset, scrollToFocusedInput, SplitListItem} = useDisplayFocusedInputUnderKeyboard(); + + const amountSections = useMemo(() => { + return sections.map((section) => ({ + ...section, + data: section.data.map((item) => ({ + ...item, + mode: CONST.IOU.SPLIT_TYPE.AMOUNT, + })), + })); + }, [sections]); + + return ( + ( + scrollToFocusedInput()} + /> + )} + onSelectRow={onSelectRow} + ref={listRef} + sections={amountSections} + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + ListItem={SplitListItem} + containerStyle={[styles.flexBasisAuto]} + listFooterContent={listFooterContent} + disableKeyboardShortcuts + shouldSingleExecuteRowSelect + canSelectMultiple={false} + shouldPreventDefaultFocusOnSelectRow + removeClippedSubviews={false} + shouldHideListOnInitialRender={false} + /> + ); +} + +SplitAmountList.displayName = 'SplitAmountList'; + +export default SplitAmountList; diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index d26e3dafd4c23..30d4358c12ab9 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -1,7 +1,6 @@ import {deepEqual} from 'fast-equals'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {InteractionManager, Keyboard, View} from 'react-native'; -import {KeyboardAwareScrollView} from 'react-native-keyboard-controller'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Button from '@components/Button'; @@ -11,12 +10,10 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import MenuItem from '@components/MenuItem'; import ScreenWrapper from '@components/ScreenWrapper'; import {useSearchContext} from '@components/Search/SearchContext'; -import SelectionList from '@components/SelectionListWithSections'; import type {SectionListDataType, SplitListItemType} from '@components/SelectionListWithSections/types'; -import TabSelectorBase from '@components/TabSelector/TabSelectorBase'; +import TabSelector from '@components/TabSelector/TabSelector'; import useAllTransactions from '@hooks/useAllTransactions'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDisplayFocusedInputUnderKeyboard from '@hooks/useDisplayFocusedInputUnderKeyboard'; import useGetIOUReportFromReportAction from '@hooks/useGetIOUReportFromReportAction'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -43,6 +40,7 @@ import {getLatestErrorMessage} from '@libs/ErrorUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {calculateSplitAmountFromPercentage, calculateSplitPercentagesFromAmounts} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; +import OnyxTabNavigator, {TabScreenWithFocusTrapWrapper, TopTab} from '@libs/Navigation/OnyxTabNavigator'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SplitExpenseParamList} from '@libs/Navigation/types'; import {isSplitAction} from '@libs/ReportSecondaryActionUtils'; @@ -51,36 +49,17 @@ import {getReportOrDraftReport, getTransactionDetails, isReportApproved, isSettl import type {TranslationPathOrText} from '@libs/TransactionPreviewUtils'; import {getChildTransactions, isManagedCardTransaction, isPerDiemRequest} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; -import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import SplitAmountList from './SplitAmountList'; +import SplitPercentageList from './SplitPercentageList'; type SplitExpensePageProps = PlatformStackScreenProps; -type TabType = { - key: ValueOf; - testID: string; - titleKey: TranslationPaths; -}; - -const tabs: TabType[] = [ - { - key: CONST.IOU.SPLIT_TYPE.AMOUNT, - testID: `split-expense-tab-${CONST.IOU.SPLIT_TYPE.AMOUNT}`, - titleKey: 'iou.amount', - }, - { - key: CONST.IOU.SPLIT_TYPE.PERCENTAGE, - testID: `split-expense-tab-${CONST.IOU.SPLIT_TYPE.PERCENTAGE}`, - titleKey: 'iou.percent', - }, -]; - function SplitExpensePage({route}: SplitExpensePageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {listRef, viewRef, footerRef, bottomOffset, scrollToFocusedInput, SplitListItem} = useDisplayFocusedInputUnderKeyboard(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['ArrowsLeftRight', 'MoneyCircle', 'Percent', 'Plus'] as const); const {reportID, transactionID, splitExpenseTransactionID, backTo} = route.params; @@ -90,9 +69,9 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const [errorMessage, setErrorMessage] = React.useState(''); - const [isPercentageMode, setIsPercentageMode] = useState(false); const searchContext = useSearchContext(); + const [selectedTab] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_SELECTED_TAB}${CONST.TAB.SPLIT_EXPENSE_TAB_TYPE}`, {canBeMissing: true}); const [draftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`, {canBeMissing: false}); const transactionReport = getReportOrDraftReport(draftTransaction?.reportID); const parentTransactionReport = getReportOrDraftReport(transactionReport?.parentReportID); @@ -132,6 +111,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const iouActions = getIOUActionForTransactions([originalTransactionID], expenseReport?.reportID); const {iouReport} = useGetIOUReportFromReportAction(iouActions.at(0)); + const isPercentageMode = selectedTab === CONST.IOU.SPLIT_TYPE.PERCENTAGE; const childTransactions = useMemo(() => getChildTransactions(allTransactions, allReports, transactionID), [allReports, allTransactions, transactionID]); const splitFieldDataFromChildTransactions = useMemo(() => childTransactions.map((currentTransaction) => initSplitExpenseItemData(currentTransaction)), [childTransactions]); const splitFieldDataFromOriginalTransaction = useMemo(() => initSplitExpenseItemData(transaction), [transaction]); @@ -274,7 +254,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const getTranslatedText = useCallback((item: TranslationPathOrText) => (item.translationPath ? translate(item.translationPath) : (item.text ?? '')), [translate]); - const [sections] = useMemo(() => { + const sections = useMemo(() => { const dotSeparator: TranslationPathOrText = {text: ` ${CONST.DOT_SEPARATOR} `}; const isTransactionMadeWithCard = isManagedCardTransaction(transaction); const showCashOrCard: TranslationPathOrText = {translationPath: isTransactionMadeWithCard ? 'iou.card' : 'iou.cash'}; @@ -319,7 +299,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { currency: draftTransaction?.currency ?? CONST.CURRENCY.USD, transactionID: item?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID, currencySymbol, - mode: isPercentageMode ? CONST.IOU.SPLIT_TYPE.PERCENTAGE : CONST.IOU.SPLIT_TYPE.AMOUNT, + mode: CONST.IOU.SPLIT_TYPE.AMOUNT, percentage, onSplitExpenseValueChange, isSelected: splitExpenseTransactionID === item.transactionID, @@ -330,7 +310,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { const newSections: Array> = [{data: items}]; - return [newSections]; + return newSections; }, [ transaction, draftTransaction?.comment?.splitExpenses, @@ -338,7 +318,6 @@ function SplitExpensePage({route}: SplitExpensePageProps) { allTransactions, transactionDetailsAmount, currencySymbol, - isPercentageMode, splitExpenseTransactionID, translate, getTranslatedText, @@ -387,7 +366,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) { ? translate('iou.totalAmountLessThanOriginal', {amount: convertToDisplayString(transactionDetailsAmount - sumOfSplitExpenses, transactionDetails.currency)}) : ''; return ( - + {(!!errorMessage || !!warningMessage) && ( ); - }, [sumOfSplitExpenses, transactionDetailsAmount, translate, transactionDetails.currency, errorMessage, styles.ph1, styles.mb2, styles.w100, onSaveSplitExpense, footerRef]); + }, [sumOfSplitExpenses, transactionDetailsAmount, translate, transactionDetails.currency, errorMessage, styles.ph1, styles.mb2, styles.w100, styles.ph5, styles.pb5, onSaveSplitExpense]); const initiallyFocusedOptionKey = useMemo( () => sections.at(0)?.data.find((option) => option.transactionID === splitExpenseTransactionID)?.keyForList, [sections, splitExpenseTransactionID], ); - const headerContent = useMemo(() => { - // Only show split tab selector if we are creating a split (not editing existing splits) - if (!isInitialSplit) { - return; - } - - const activeTabKey = isPercentageMode ? CONST.IOU.SPLIT_TYPE.PERCENTAGE : CONST.IOU.SPLIT_TYPE.AMOUNT; - - return ( - ({ - key: tab.key, - title: translate(tab.titleKey), - icon: tab.key === CONST.IOU.SPLIT_TYPE.AMOUNT ? expensifyIcons.MoneyCircle : expensifyIcons.Percent, - testID: tab.testID, - }))} - activeTabKey={activeTabKey} - onTabPress={(key) => setIsPercentageMode(key === CONST.IOU.SPLIT_TYPE.PERCENTAGE)} - shouldShowLabelWhenInactive - equalWidth - /> - ); - }, [expensifyIcons.MoneyCircle, expensifyIcons.Percent, isPercentageMode, isInitialSplit, translate]); - const headerTitle = useMemo(() => { if (splitExpenseTransactionID) { return translate('iou.editSplits'); @@ -445,6 +400,21 @@ function SplitExpensePage({route}: SplitExpensePageProps) { return isPercentageMode ? translate('iou.splitByPercentage') : translate('iou.split'); }, [splitExpenseTransactionID, isPercentageMode, translate]); + const onSelectRow = useCallback( + (item: SplitListItemType) => { + if (!item.isEditable) { + setCannotBeEditedModalVisible(true); + return; + } + Keyboard.dismiss(); + // eslint-disable-next-line @typescript-eslint/no-deprecated + InteractionManager.runAfterInteractions(() => { + initDraftSplitExpenseDataForEdit(draftTransaction, item.transactionID, item.reportID ?? reportID); + }); + }, + [draftTransaction, reportID], + ); + return ( - { - scrollToFocusedInput(); - }} - > + Navigation.goBack(backTo)} /> - ( - + + + {() => ( + + + + {footerContent} + + + )} + + + {() => ( + + + + {footerContent} + + + )} + + + + ) : ( + + - )} - onSelectRow={(item) => { - if (!item.isEditable) { - setCannotBeEditedModalVisible(true); - return; - } - Keyboard.dismiss(); - // eslint-disable-next-line @typescript-eslint/no-deprecated - InteractionManager.runAfterInteractions(() => { - initDraftSplitExpenseDataForEdit(draftTransaction, item.transactionID, item.reportID ?? reportID); - }); - }} - ref={listRef} - sections={sections} - initiallyFocusedOptionKey={initiallyFocusedOptionKey} - ListItem={SplitListItem} - containerStyle={[styles.flexBasisAuto]} - headerContent={headerContent} - footerContent={footerContent} - listFooterContent={listFooterContent} - disableKeyboardShortcuts - shouldSingleExecuteRowSelect - canSelectMultiple={false} - shouldPreventDefaultFocusOnSelectRow - removeClippedSubviews={false} - /> + {footerContent} + + )} >; + /** The initially focused option key. */ + initiallyFocusedOptionKey: string | undefined; + /** Callback when a row is selected. */ + onSelectRow: (item: SplitListItemType) => void; + /** Footer content to render at the bottom of the list. */ + listFooterContent?: React.JSX.Element | null; +}; + +/** + * Dedicated component for the Percentage tab in Split Expense flow. + * Renders split items with percentage inputs, managing its own scroll/height state. + */ +function SplitPercentageList({sections, initiallyFocusedOptionKey, onSelectRow, listFooterContent}: SplitPercentageListProps) { + const styles = useThemeStyles(); + const {listRef, bottomOffset, scrollToFocusedInput, SplitListItem} = useDisplayFocusedInputUnderKeyboard(); + + const percentageSections = useMemo(() => { + return sections.map((section) => ({ + ...section, + data: section.data.map((item) => ({ + ...item, + mode: CONST.IOU.SPLIT_TYPE.PERCENTAGE, + })), + })); + }, [sections]); + + return ( + ( + scrollToFocusedInput()} + /> + )} + onSelectRow={onSelectRow} + ref={listRef} + sections={percentageSections} + initiallyFocusedOptionKey={initiallyFocusedOptionKey} + ListItem={SplitListItem} + containerStyle={[styles.flexBasisAuto]} + listFooterContent={listFooterContent} + disableKeyboardShortcuts + shouldSingleExecuteRowSelect + canSelectMultiple={false} + shouldPreventDefaultFocusOnSelectRow + removeClippedSubviews={false} + shouldHideListOnInitialRender={false} + isPercentageMode + /> + ); +} + +SplitPercentageList.displayName = 'SplitPercentageList'; + +export default SplitPercentageList; diff --git a/src/types/onyx/SplitSelectedTabRequest.ts b/src/types/onyx/SplitSelectedTabRequest.ts new file mode 100644 index 0000000000000..b4166d63dadb7 --- /dev/null +++ b/src/types/onyx/SplitSelectedTabRequest.ts @@ -0,0 +1,7 @@ +import type {ValueOf} from 'type-fest'; +import type CONST from '@src/CONST'; + +/** Selectable IOU request split tabs */ +type SplitSelectedTabRequest = ValueOf; + +export default SplitSelectedTabRequest; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index a1e57eebd55e7..3ea8f4418ad44 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -113,6 +113,7 @@ import type SelectedTabRequest from './SelectedTabRequest'; import type Session from './Session'; import type ShareTempFile from './ShareTempFile'; import type SidePanel from './SidePanel'; +import type SplitSelectedTabRequest from './SplitSelectedTabRequest'; import type StripeCustomerID from './StripeCustomerID'; import type SupportalPermissionDenied from './SupportalPermissionDenied'; import type Task from './Task'; @@ -227,6 +228,7 @@ export type { ScreenShareRequest, SecurityGroup, SelectedTabRequest, + SplitSelectedTabRequest, Session, Task, TaxRate, From 5bd8bcf5e0dba0241f08839caae2268b93a36ddb Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 17 Dec 2025 13:51:59 -0800 Subject: [PATCH 25/27] fix: navigation go back - not found --- src/libs/Navigation/linkingConfig/config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f5fab5c6926e6..55ed3cb8603b0 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1477,6 +1477,14 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE]: { path: ROUTES.SPLIT_EXPENSE.route, exact: true, + screens: { + [CONST.IOU.SPLIT_TYPE.AMOUNT]: { + path: CONST.IOU.SPLIT_TYPE.AMOUNT, + }, + [CONST.IOU.SPLIT_TYPE.PERCENTAGE]: { + path: CONST.IOU.SPLIT_TYPE.PERCENTAGE, + }, + }, }, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_EDIT]: { path: ROUTES.SPLIT_EXPENSE_EDIT.route, From 109bf29e7399484618eda3c9d42a3e13d700b597 Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 17 Dec 2025 14:15:06 -0800 Subject: [PATCH 26/27] fix: typecheck --- src/pages/iou/SplitExpensePage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pages/iou/SplitExpensePage.tsx b/src/pages/iou/SplitExpensePage.tsx index f9363bfa542eb..53ee30f0f37f7 100644 --- a/src/pages/iou/SplitExpensePage.tsx +++ b/src/pages/iou/SplitExpensePage.tsx @@ -439,7 +439,6 @@ function SplitExpensePage({route}: SplitExpensePageProps) { id={CONST.TAB.SPLIT_EXPENSE_TAB_TYPE} defaultSelectedTab={CONST.IOU.SPLIT_TYPE.AMOUNT} tabBar={TabSelector} - disableSwipe > {() => ( From c3df553ec5fc0b628313fee9f313c9ba6ec0488b Mon Sep 17 00:00:00 2001 From: Kevin Brian Bader Date: Wed, 17 Dec 2025 23:12:56 -0800 Subject: [PATCH 27/27] fix: react compiler --- src/components/TabSelector/TabSelectorBase.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/TabSelector/TabSelectorBase.tsx b/src/components/TabSelector/TabSelectorBase.tsx index 006407fb54fbc..be7a412814c7c 100644 --- a/src/components/TabSelector/TabSelectorBase.tsx +++ b/src/components/TabSelector/TabSelectorBase.tsx @@ -1,4 +1,4 @@ -import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; import {View} from 'react-native'; // eslint-disable-next-line no-restricted-imports import type {Animated} from 'react-native'; @@ -74,13 +74,13 @@ function TabSelectorBase({ const routesLength = tabs.length; - const defaultAffectedAnimatedTabs = useMemo(() => Array.from({length: routesLength}, (_v, i) => i), [routesLength]); + const defaultAffectedAnimatedTabs = Array.from({length: routesLength}, (_v, i) => i); const [affectedAnimatedTabs, setAffectedAnimatedTabs] = useState(defaultAffectedAnimatedTabs); const viewRef = useRef(null); const [selectorWidth, setSelectorWidth] = useState(0); const [selectorX, setSelectorX] = useState(0); - const activeIndex = useMemo(() => tabs.findIndex((tab) => tab.key === activeTabKey), [tabs, activeTabKey]); + const activeIndex = tabs.findIndex((tab) => tab.key === activeTabKey); // After a tab change, reset affectedAnimatedTabs once the transition is done so // tabs settle back into the default animated state. @@ -92,12 +92,12 @@ function TabSelectorBase({ return () => clearTimeout(timerID); }, [defaultAffectedAnimatedTabs, activeIndex]); - const measure = useCallback(() => { + const measure = () => { viewRef.current?.measureInWindow((x, _y, width) => { setSelectorX(x); setSelectorWidth(width); }); - }, []); + }; // Measure location/width after initial mount and when layout animations settle. useLayoutEffect(() => {