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();