diff --git a/assets/images/percent.svg b/assets/images/percent.svg
new file mode 100644
index 0000000000000..651128b63542a
--- /dev/null
+++ b/assets/images/percent.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index eb408a6b85e36..90871325a374b 100755
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -2908,6 +2908,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: {
@@ -5440,6 +5444,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 2451c5b4d21fa..408309dc8f873 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_',
@@ -1109,6 +1112,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/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts
index 10a0c7fada659..50db48729da60 100644
--- a/src/components/Icon/chunks/expensify-icons.chunk.ts
+++ b/src/components/Icon/chunks/expensify-icons.chunk.ts
@@ -160,6 +160,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';
@@ -359,6 +360,7 @@ const Expensicons = {
Paperclip,
Pause,
Pencil,
+ Percent,
Phone,
Pin,
Play,
diff --git a/src/components/PercentageForm.tsx b/src/components/PercentageForm.tsx
index 6e54e2e954ea7..94a9c7aad748b 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;
@@ -19,11 +19,17 @@ type PercentageFormProps = {
/** Custom label for the TextInput */
label?: string;
+ /** 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, 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);
@@ -31,7 +37,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 +45,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, allowDecimal)) {
return;
}
const strippedAmount = stripCommaFromAmount(newAmountWithoutSpaces);
onInputChange?.(strippedAmount);
},
- [onInputChange],
+ [allowExceedingHundred, allowDecimal, onInputChange],
);
const formattedAmount = replaceAllDigits(currentAmount, toLocaleDigit);
diff --git a/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx b/src/components/SelectionListWithSections/BaseSelectionListWithSections.tsx
index c4c661fc12d5f..cb8c139c5a1ef 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/SplitExpense/SplitAmountDisplay.tsx b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx
new file mode 100644
index 0000000000000..1035934ab507f
--- /dev/null
+++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountDisplay.tsx
@@ -0,0 +1,47 @@
+import React, {useState} from 'react';
+import {View} from 'react-native';
+import type {SplitListItemType} from '@components/SelectionListWithSections/types';
+import Text from '@components/Text';
+import useThemeStyles from '@hooks/useThemeStyles';
+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;
+};
+
+function SplitAmountDisplay({splitItem, contentWidth = '100%', 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)}
+
+
+ );
+}
+
+SplitAmountDisplay.displayName = 'SplitAmountDisplay';
+
+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..73a9ef73308ee
--- /dev/null
+++ b/src/components/SelectionListWithSections/SplitExpense/SplitAmountInput.tsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import type {BlurEvent} from 'react-native';
+import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput';
+import type {SplitListItemType} from '@components/SelectionListWithSections/types';
+import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
+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;
+};
+
+function SplitAmountInput({splitItem, formattedOriginalAmount, contentWidth, onSplitExpenseValueChange, focusHandler, onInputBlur, inputCallbackRef}: SplitAmountInputProps) {
+ const styles = useThemeStyles();
+
+ if (splitItem.isEditable) {
+ return (
+
+ );
+ }
+ return (
+
+ );
+}
+
+SplitAmountInput.displayName = 'SplitAmountInput';
+
+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..cbd05b5ba34b2
--- /dev/null
+++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageDisplay.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import {View} from 'react-native';
+import type {SplitListItemType} from '@components/SelectionListWithSections/types';
+import Text from '@components/Text';
+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;
+};
+
+function SplitPercentageDisplay({splitItem, contentWidth}: SplitPercentageDisplayProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+
+ {splitItem.percentage}%
+
+
+ );
+}
+
+SplitPercentageDisplay.displayName = 'SplitPercentageDisplay';
+
+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..397e34337c17a
--- /dev/null
+++ b/src/components/SelectionListWithSections/SplitExpense/SplitPercentageInput.tsx
@@ -0,0 +1,65 @@
+import React from 'react';
+import type {BlurEvent} from 'react-native';
+import PercentageForm from '@components/PercentageForm';
+import type {SplitListItemType} from '@components/SelectionListWithSections/types';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import SplitPercentageDisplay from './SplitPercentageDisplay';
+
+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}: SplitPercentageInputProps) {
+ 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
+ allowDecimal
+ />
+ );
+ }
+ return (
+
+ );
+}
+
+SplitPercentageInput.displayName = 'SplitPercentageInput';
+
+export default SplitPercentageInput;
diff --git a/src/components/SelectionListWithSections/SplitListItem.tsx b/src/components/SelectionListWithSections/SplitListItem.tsx
index 3e99fe354dc8c..a296d5b9f0eee 100644
--- a/src/components/SelectionListWithSections/SplitListItem.tsx
+++ b/src/components/SelectionListWithSections/SplitListItem.tsx
@@ -1,9 +1,6 @@
-import React, {useCallback, useLayoutEffect, useRef} from 'react';
+import React, {useCallback, useLayoutEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import Icon from '@components/Icon';
-// eslint-disable-next-line no-restricted-imports
-import * as Expensicons from '@components/Icon/Expensicons';
-import MoneyRequestAmountInput from '@components/MoneyRequestAmountInput';
import Text from '@components/Text';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useAnimatedHighlightStyle from '@hooks/useAnimatedHighlightStyle';
@@ -17,6 +14,8 @@ 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 SplitListItemInput from './SplitListItemInput';
import type {ListItem, SplitListItemProps, SplitListItemType} from './types';
function SplitListItem({
@@ -32,7 +31,7 @@ function SplitListItem({
onInputFocus,
onInputBlur,
}: SplitListItemProps) {
- const icons = useMemoizedLazyExpensifyIcons(['Folder', 'Tag']);
+ const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Folder', 'Tag'] as const);
const theme = useTheme();
const styles = useThemeStyles();
const {didScreenTransitionEnd} = useScreenWrapperTransitionStatus();
@@ -41,9 +40,7 @@ function SplitListItem({
const formattedOriginalAmount = convertToDisplayStringWithoutCurrency(splitItem.originalAmount, splitItem.currency);
- const onSplitExpenseAmountChange = (amount: string) => {
- splitItem.onSplitExpenseAmountChange(splitItem.transactionID, Number(amount));
- };
+ const onSplitExpenseValueChange = useCallback((value: string) => splitItem.onSplitExpenseValueChange(splitItem.transactionID, Number(value), splitItem.mode), [splitItem]);
const inputRef = useRef(null);
@@ -60,6 +57,7 @@ function SplitListItem({
const isBottomVisible = !!splitItem.category || !!splitItem.tags?.at(0);
const contentWidth = (formattedOriginalAmount.length + 1) * CONST.CHARACTER_WIDTH;
+ const [percentageDraft, setPercentageDraft] = useState();
const focusHandler = useCallback(() => {
if (!onInputFocus) {
return;
@@ -84,6 +82,8 @@ function SplitListItem({
inputRef.current = ref;
};
+ const isPercentageMode = splitItem.mode === CONST.IOU.SPLIT_TYPE.PERCENTAGE;
+
return (
({
{splitItem.headerText}
-
-
+
+
({
>
{splitItem.merchant}
+ {isPercentageMode && (
+
+ )}
@@ -164,39 +170,24 @@ function SplitListItem({
-
{!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 efa9a1c55a6f3..5c2cbe9346df8 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 181c703965d0e..3b3782d144ffc 100644
--- a/src/components/SelectionListWithSections/types.ts
+++ b/src/components/SelectionListWithSections/types.ts
@@ -16,6 +16,7 @@ import type {
} from 'react-native';
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
import type {AnimatedStyle} from 'react-native-reanimated';
+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';
@@ -563,8 +564,16 @@ type SplitListItemType = ListItem &
/** Indicates whether a split wasn't approved, paid etc. when report.statusNum < CONST.REPORT.STATUS_NUM.CLOSED */
isEditable: boolean;
- /** Function for updating amount */
- onSplitExpenseAmountChange: (currentItemTransactionID: string, value: number) => 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 value (amount or percentage based on mode)
+ */
+ onSplitExpenseValueChange: (transactionID: string, value: number, mode: ValueOf) => void;
};
type SplitListItemProps = ListItemProps;
@@ -1047,6 +1056,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 e2bc539e79645..204536cdad856 100644
--- a/src/components/TabSelector/TabSelector.tsx
+++ b/src/components/TabSelector/TabSelector.tsx
@@ -1,22 +1,15 @@
import type {MaterialTopTabBarProps} from '@react-navigation/material-top-tabs';
import {TabActions} from '@react-navigation/native';
-import React, {useCallback, useEffect, useLayoutEffect, useRef, useState} from 'react';
-import {View} from 'react-native';
+import React, {useMemo} from 'react';
import type {TupleToUnion} from 'type-fest';
import FocusTrapContainerElement from '@components/FocusTrap/FocusTrapContainerElement';
-// eslint-disable-next-line no-restricted-imports
-import * as Expensicons from '@components/Icon/Expensicons';
import type {LocaleContextProps} from '@components/LocaleContextProvider';
-import useIsResizing from '@hooks/useIsResizing';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
-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';
+import TabSelectorBase from './TabSelectorBase';
+import type {TabSelectorBaseItem} from './TabSelectorBase';
type TabSelectorProps = MaterialTopTabBarProps & {
/* Callback fired when tab is pressed */
@@ -44,7 +37,7 @@ type IconTitleAndTestID = {
testID?: string;
};
-const MEMOIZED_LAZY_TAB_SELECTOR_ICONS = ['CalendarSolid', 'UploadAlt', 'User', 'Car', 'Hashtag', 'Map', 'Pencil', 'Crosshair'] as const;
+const MEMOIZED_LAZY_TAB_SELECTOR_ICONS = ['CalendarSolid', 'UploadAlt', 'User', 'Car', 'Hashtag', 'Map', 'Pencil', 'ReceiptScan', 'Receipt', 'MoneyCircle', 'Percent', 'Crosshair'] as const;
function getIconTitleAndTestID(
icons: Record, IconAsset>,
@@ -61,7 +54,7 @@ function getIconTitleAndTestID(
case CONST.TAB_REQUEST.MANUAL:
return {icon: icons.Pencil, title: translate('tabSelector.manual'), testID: 'manual'};
case CONST.TAB_REQUEST.SCAN:
- return {icon: Expensicons.ReceiptScan, title: translate('tabSelector.scan'), testID: 'scan'};
+ return {icon: icons.ReceiptScan, title: translate('tabSelector.scan'), testID: 'scan'};
case CONST.TAB.NEW_CHAT:
return {icon: icons.User, title: translate('tabSelector.chat'), testID: 'chat'};
case CONST.TAB.NEW_ROOM:
@@ -71,7 +64,7 @@ function getIconTitleAndTestID(
case CONST.TAB.SHARE.SHARE:
return {icon: icons.UploadAlt, title: translate('common.share'), testID: 'share'};
case CONST.TAB.SHARE.SUBMIT:
- return {icon: Expensicons.Receipt, title: translate('common.submit'), testID: 'submit'};
+ return {icon: icons.Receipt, title: translate('common.submit'), testID: 'submit'};
case CONST.TAB_REQUEST.PER_DIEM:
return {icon: icons.CalendarSolid, title: translate('common.perDiem'), testID: 'perDiem'};
case CONST.TAB_REQUEST.DISTANCE_MAP:
@@ -80,6 +73,10 @@ function getIconTitleAndTestID(
return {icon: icons.Pencil, title: translate('tabSelector.manual'), testID: 'distanceManual'};
case CONST.TAB_REQUEST.DISTANCE_GPS:
return {icon: icons.Crosshair, title: translate('tabSelector.gps'), testID: 'distanceGPS'};
+ 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.`);
}
@@ -98,99 +95,59 @@ function TabSelector({
}: TabSelectorProps) {
const icons = useMemoizedLazyExpensifyIcons(MEMOIZED_LAZY_TAB_SELECTOR_ICONS);
const {translate} = useLocalize();
- const theme = useTheme();
- const styles = useThemeStyles();
- const defaultAffectedAnimatedTabs = Array.from({length: state.routes.length}, (v, i) => i);
- 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(icons, route.name, translate);
+ return {
+ key: route.name,
+ icon,
+ title,
+ testID,
+ };
+ }),
+ [icons, 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(icons, 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..be7a412814c7c
--- /dev/null
+++ b/src/components/TabSelector/TabSelectorBase.tsx
@@ -0,0 +1,187 @@
+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';
+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';
+
+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). */
+ position?: 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;
+};
+
+/**
+ * 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.
+ */
+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 = 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 = 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.
+ useEffect(() => {
+ const timerID = setTimeout(() => {
+ setAffectedAnimatedTabs(defaultAffectedAnimatedTabs);
+ }, CONST.ANIMATED_TRANSITION);
+
+ return () => clearTimeout(timerID);
+ }, [defaultAffectedAnimatedTabs, activeIndex]);
+
+ const measure = () => {
+ 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;
+ }
+
+ setAffectedAnimatedTabs([activeIndex, index]);
+ onTabPress(tab.key);
+ };
+
+ return (
+
+ );
+ })}
+
+ );
+}
+
+TabSelectorBase.displayName = 'TabSelectorBase';
+
+export default TabSelectorBase;
+export type {TabSelectorBaseItem, TabSelectorBaseProps};
diff --git a/src/languages/de.ts b/src/languages/de.ts
index dcd1b995d9f32..5053a7481cf12 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -1137,6 +1137,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: 'Betrag',
+ percent: 'Prozent',
taxAmount: 'Steuerbetrag',
taxRate: 'Steuersatz',
approve: ({
@@ -1151,6 +1152,7 @@ const translations: TranslationDeepObject = {
split: 'Aufteilen',
splitExpense: 'Ausgabe aufteilen',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} von ${merchant}`,
+ splitByPercentage: 'Nach Prozentsatz aufteilen',
addSplit: 'Aufteilung hinzufügen',
makeSplitsEven: 'Aufteilungen gleichmäßig machen',
editSplits: 'Aufteilungen bearbeiten',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 2a3786d2416ac..5cd13e5535c48 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -1119,6 +1119,7 @@ const translations = {
},
iou: {
amount: 'Amount',
+ percent: 'Percent',
taxAmount: 'Tax amount',
taxRate: 'Tax rate',
approve: ({formattedAmount}: {formattedAmount?: string} = {}) => (formattedAmount ? `Approve ${formattedAmount}` : 'Approve'),
@@ -1129,6 +1130,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 0a8bceea1c94b..eb8b02c3b70e6 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -799,6 +799,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: 'Importe',
+ percent: 'Porcentaje',
taxAmount: 'Importe del impuesto',
taxRate: 'Tasa de impuesto',
approve: ({formattedAmount} = {}) => (formattedAmount ? `Aprobar ${formattedAmount}` : 'Aprobar'),
@@ -809,6 +810,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 47cfe5f4831d3..abad71aae04fb 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -1137,6 +1137,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: 'Montant',
+ percent: 'Pourcentage',
taxAmount: 'Montant de la taxe',
taxRate: 'Taux de taxe',
approve: ({
@@ -1151,6 +1152,7 @@ const translations: TranslationDeepObject = {
split: 'Fractionner',
splitExpense: 'Fractionner la dépense',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} de ${merchant}`,
+ splitByPercentage: 'Répartir par pourcentage',
addSplit: 'Ajouter une ventilation',
makeSplitsEven: 'Rendre les répartitions égales',
editSplits: 'Modifier les répartitions',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 1e8e6e7d683d0..f925c7f06e57c 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -1132,6 +1132,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: 'Importo',
+ percent: 'Percentuale',
taxAmount: 'Importo imposta',
taxRate: 'Aliquota fiscale',
approve: ({
@@ -1146,6 +1147,7 @@ const translations: TranslationDeepObject = {
split: 'Dividi',
splitExpense: 'Dividi spesa',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} da ${merchant}`,
+ splitByPercentage: 'Ripartisci per percentuale',
addSplit: 'Aggiungi ripartizione',
makeSplitsEven: 'Rendi le suddivisioni uguali',
editSplits: 'Modifica suddivisioni',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index c9e2b130f5852..958b2413ce380 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -1134,6 +1134,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: '金額',
+ percent: 'パーセント',
taxAmount: '税額',
taxRate: '税率',
approve: ({
@@ -1148,6 +1149,7 @@ const translations: TranslationDeepObject = {
split: '分割',
splitExpense: '経費を分割',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${merchant} からの ${amount}`,
+ splitByPercentage: '割合で分割',
addSplit: '分割を追加',
makeSplitsEven: '分割を均等にする',
editSplits: '分割を編集',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index df2eed9a2b280..7d8451cc2e9b0 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -1132,6 +1132,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: 'Bedrag',
+ percent: 'Procent',
taxAmount: 'Belastingbedrag',
taxRate: 'Belastingtarief',
approve: ({
@@ -1146,6 +1147,7 @@ const translations: TranslationDeepObject = {
split: 'Splitsen',
splitExpense: 'Uitgave splitsen',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} van ${merchant}`,
+ splitByPercentage: 'Splitsen op percentage',
addSplit: 'Splits toevoegen',
makeSplitsEven: 'Verdeel bedragen gelijk',
editSplits: 'Splits bewerken',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 933523a02dc08..5af82b408f735 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -1131,6 +1131,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: 'Kwota',
+ percent: 'Procent',
taxAmount: 'Kwota podatku',
taxRate: 'Stawka podatku',
approve: ({
@@ -1145,6 +1146,7 @@ const translations: TranslationDeepObject = {
split: 'Podziel',
splitExpense: 'Podziel wydatki',
splitExpenseSubtitle: ({amount, merchant}: SplitExpenseSubtitleParams) => `${amount} od ${merchant}`,
+ splitByPercentage: 'Podziel procentowo',
addSplit: 'Dodaj podział',
makeSplitsEven: 'Podziel równo',
editSplits: 'Edytuj podziały',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index aca1af131760e..34115450d5700 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -1131,6 +1131,7 @@ const translations: TranslationDeepObject = {
},
iou: {
amount: 'Valor',
+ percent: 'Porcentagem',
taxAmount: 'Valor do imposto',
taxRate: 'Alíquota de imposto',
approve: ({
@@ -1145,6 +1146,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: 'Dividir igualmente',
editSplits: 'Editar divisões',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 8d8abe17ffcdc..530905a3d846f 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.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: '编辑拆分',
diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts
index aa58de156c28b..3682b28943dc2 100644
--- a/src/libs/IOUUtils.ts
+++ b/src/libs/IOUUtils.ts
@@ -93,6 +93,76 @@ 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]
+ * - 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);
+ // 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 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
+ */
+function calculateSplitPercentagesFromAmounts(amountsInCents: number[], totalInCents: number): number[] {
+ const totalAbs = Math.abs(totalInCents);
+
+ if (totalAbs <= 0 || amountsInCents.length === 0) {
+ return amountsInCents.map(() => 0);
+ }
+
+ const amountsAbs = amountsInCents.map((amount) => Math.abs(amount ?? 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 floored percentages as-is
+ // (the sum may not be 100, but that's expected when there's a validation error)
+ if (amountsTotal !== totalAbs) {
+ return flooredPercentages;
+ }
+
+ // 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) {
+ 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 = amountsAbs.length - 1; // fallback to last
+ for (let i = 0; i < amountsAbs.length; i += 1) {
+ if (amountsAbs.at(i) === maxAmount) {
+ lastMaxIndex = i;
+ }
+ }
+
+ const adjustedPercentages = [...flooredPercentages];
+ adjustedPercentages[lastMaxIndex] = roundToOneDecimal((adjustedPercentages.at(lastMaxIndex) ?? 0) + remainder);
+
+ return adjustedPercentages;
+}
+
/**
* 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.
@@ -244,6 +314,8 @@ function formatCurrentUserToAttendee(currentUser?: PersonalDetails, reportID?: s
export {
calculateAmount,
+ calculateSplitAmountFromPercentage,
+ calculateSplitPercentagesFromAmounts,
insertTagIntoTransactionTagsString,
isIOUReportPendingCurrencyConversion,
isMovingTransactionFromTrackExpense,
diff --git a/src/libs/MoneyRequestUtils.ts b/src/libs/MoneyRequestUtils.ts
index ade3c386078d5..da7bcb6a0ad05 100644
--- a/src/libs/MoneyRequestUtils.ts
+++ b/src/libs/MoneyRequestUtils.ts
@@ -54,10 +54,20 @@ 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.
+ * The `allowDecimal` flag enables one decimal place (0.1 precision) for more granular percentage splits.
*/
-function validatePercentage(amount: string): boolean {
- const regexString = '^(100|[0-9]{1,2})$';
+function validatePercentage(amount: string, allowExceedingHundred = false, allowDecimal = false): boolean {
+ if (allowExceedingHundred) {
+ const regex = allowDecimal ? /^\d*\.?\d?$/u : /^\d*$/u;
+ return amount === '' || regex.test(amount);
+ }
+
+ 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/src/libs/Navigation/OnyxTabNavigator.tsx b/src/libs/Navigation/OnyxTabNavigator.tsx
index a7239b76774bf..43a281e83a0b4 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/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,
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 3bece16f8e86b..53ee30f0f37f7 100644
--- a/src/pages/iou/SplitExpensePage.tsx
+++ b/src/pages/iou/SplitExpensePage.tsx
@@ -1,22 +1,21 @@
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';
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';
-import SelectionList from '@components/SelectionListWithSections';
import type {SectionListDataType, SplitListItemType} from '@components/SelectionListWithSections/types';
+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';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
@@ -39,7 +38,9 @@ import DateUtils from '@libs/DateUtils';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
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,13 +52,15 @@ import CONST from '@src/CONST';
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;
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;
@@ -65,8 +68,10 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
const [cannotBeEditedModalVisible, setCannotBeEditedModalVisible] = useState(false);
const [errorMessage, setErrorMessage] = React.useState('');
+
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);
@@ -106,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]);
@@ -233,28 +239,38 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
transactionViolations,
]);
- const onSplitExpenseAmountChange = useCallback(
- (currentItemTransactionID: string, value: number) => {
- const amountInCents = convertToBackendAmount(value);
- 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],
+ [draftTransaction, transactionDetailsAmount],
);
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'};
+ 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 currentItemReport = getReportOrDraftReport(currentTransaction?.reportID);
const isApproved = isReportApproved({report: currentItemReport});
const isSettled = isSettledReportUtils(currentItemReport?.reportID);
const isCancelled = currentItemReport && currentItemReport?.isCancelledIOU;
+ const percentage = adjustedPercentages.at(index) ?? 0;
const date = DateUtils.formatWithUTCTimeZone(
item.created,
@@ -283,7 +299,9 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
currency: draftTransaction?.currency ?? CONST.CURRENCY.USD,
transactionID: item?.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID,
currencySymbol,
- onSplitExpenseAmountChange,
+ mode: CONST.IOU.SPLIT_TYPE.AMOUNT,
+ percentage,
+ onSplitExpenseValueChange,
isSelected: splitExpenseTransactionID === item.transactionID,
keyForList: item?.transactionID,
isEditable: (item.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.CLOSED,
@@ -292,7 +310,7 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
const newSections: Array> = [{data: items}];
- return [newSections];
+ return newSections;
}, [
transaction,
draftTransaction?.comment?.splitExpenses,
@@ -300,33 +318,47 @@ function SplitExpensePage({route}: SplitExpensePageProps) {
allTransactions,
transactionDetailsAmount,
currencySymbol,
- onSplitExpenseAmountChange,
splitExpenseTransactionID,
translate,
getTranslatedText,
+ onSplitExpenseValueChange,
]);
+ const isInitialSplit = useMemo(() => childTransactions.length === 0, [childTransactions.length]);
+
const listFooterContent = useMemo(() => {
- const shouldShowMakeSplitsEven = childTransactions.length === 0;
return (
- {shouldShowMakeSplitsEven && (
+ {isInitialSplit && (
)}
);
- }, [onAddSplitExpense, onMakeSplitsEven, translate, childTransactions.length, shouldUseNarrowLayout, styles.w100, styles.ph4, styles.flexColumn, styles.mt1, styles.mb3]);
+ }, [
+ onAddSplitExpense,
+ onMakeSplitsEven,
+ translate,
+ isInitialSplit,
+ shouldUseNarrowLayout,
+ styles.w100,
+ styles.ph4,
+ styles.flexColumn,
+ styles.mt1,
+ styles.mb3,
+ expensifyIcons.ArrowsLeftRight,
+ expensifyIcons.Plus,
+ ]);
const footerContent = useMemo(() => {
const shouldShowWarningMessage = sumOfSplitExpenses < transactionDetailsAmount;
@@ -334,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 headerTitle = useMemo(() => {
+ if (splitExpenseTransactionID) {
+ return translate('iou.editSplits');
+ }
+ 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]}
- 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/styles/index.ts b/src/styles/index.ts
index 282524bcabd66..85dd06a5f5506 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -1251,6 +1251,11 @@ const staticStyles = (theme: ThemeColors) =>
borderColor: 'transparent',
},
+ removeSpacing: {
+ marginVertical: 0,
+ paddingHorizontal: 0,
+ },
+
outlinedButton: {
backgroundColor: 'transparent',
borderColor: theme.border,
@@ -1261,6 +1266,20 @@ const staticStyles = (theme: ThemeColors) =>
textAlign: 'right',
},
+ optionRowAmountMobileInputContainer: {
+ width: variables.splitExpenseAmountMobileWidth,
+ },
+
+ optionRowPercentInputContainer: {
+ width: variables.splitExpensePercentageMobileWidth,
+ },
+
+ optionRowPercentInput: {
+ width: variables.splitExpensePercentageWidth,
+ textAlign: 'right',
+ marginRight: 2,
+ },
+
textInputLabelContainer: {
position: 'absolute',
left: 8,
@@ -5594,7 +5613,7 @@ const dynamicStyles = (theme: ThemeColors) =>
paddingBottom: bottomSafeAreaOffset,
}),
- getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number) => ({
+ getSplitListItemAmountStyle: (inputMarginLeft: number, amountWidth: number | string) => ({
marginLeft: inputMarginLeft,
width: amountWidth,
marginRight: 4,
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index f26e89452ffb7..4adfd699bbece 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;
diff --git a/src/styles/variables.ts b/src/styles/variables.ts
index 49e33bcd21018..b4cee04e7b788 100644
--- a/src/styles/variables.ts
+++ b/src/styles/variables.ts
@@ -385,4 +385,9 @@ export default {
// Report search header max width
reportSearchHeaderMaxWidth: 700,
+
+ // Split expense tabs
+ splitExpenseAmountMobileWidth: 82,
+ splitExpensePercentageWidth: 42,
+ splitExpensePercentageMobileWidth: 62,
} as const;
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 75de16dffe7c6..2dab239af2f68 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -115,6 +115,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';
@@ -229,6 +230,7 @@ export type {
ScreenShareRequest,
SecurityGroup,
SelectedTabRequest,
+ SplitSelectedTabRequest,
Session,
Task,
TaxRate,
diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts
index 1b70dd59d4c8e..a4d64034e77b9 100644
--- a/tests/unit/IOUUtilsTest.ts
+++ b/tests/unit/IOUUtilsTest.ts
@@ -173,6 +173,136 @@ describe('IOUUtils', () => {
});
});
+ describe('calculateSplitAmountFromPercentage', () => {
+ test('Basic percentage calculation and rounding', () => {
+ expect(IOUUtils.calculateSplitAmountFromPercentage(20000, 25)).toBe(5000);
+ 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', () => {
+ expect(IOUUtils.calculateSplitAmountFromPercentage(20000, -10)).toBe(0);
+ expect(IOUUtils.calculateSplitAmountFromPercentage(20000, 150)).toBe(20000);
+ expect(IOUUtils.calculateSplitAmountFromPercentage(-20000, 25)).toBe(5000);
+ });
+ });
+
+ describe('calculateSplitPercentagesFromAmounts', () => {
+ 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);
+ });
+
+ 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('Returns percentages with one decimal place', () => {
+ const totalInCents = 2300;
+ const amounts = [766, 766, 768];
+ const percentages = IOUUtils.calculateSplitPercentagesFromAmounts(amounts, totalInCents);
+
+ // 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', () => {
+ 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);
+
+ // 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('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 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', () => {
test('Inserting a tag into tag string should update the tag', () => {
expect(IOUUtils.insertTagIntoTransactionTagsString(':NY:Texas', 'California', 2, true)).toBe(':NY:California');
diff --git a/tests/unit/MoneyRequestUtilsTest.ts b/tests/unit/MoneyRequestUtilsTest.ts
index e3aa9d230a48c..7a9e7cb9399ad 100644
--- a/tests/unit/MoneyRequestUtilsTest.ts
+++ b/tests/unit/MoneyRequestUtilsTest.ts
@@ -1,5 +1,5 @@
import {isValidPerDiemExpenseAmount} from '@libs/actions/IOU';
-import {handleNegativeAmountFlipping, validateAmount} from '@libs/MoneyRequestUtils';
+import {handleNegativeAmountFlipping, validateAmount, validatePercentage} from '@libs/MoneyRequestUtils';
import CONST from '@src/CONST';
import type {TransactionCustomUnit} from '@src/types/onyx/Transaction';
@@ -23,6 +23,62 @@ 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);
+ });
+
+ 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', () => {
it('should toggle negative and remove dash when allowFlippingAmount is true and amount starts with -', () => {
const mockToggleNegative = jest.fn();