Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
d0701e6
Add 'Split by percentage' to the split expense table for new splits
ikevin127 Nov 9, 2025
8a1a1a9
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 10, 2025
9930a8b
added illustration, translations, refactoring and tests
ikevin127 Nov 10, 2025
c8886b6
svg compression
ikevin127 Nov 10, 2025
482d706
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 10, 2025
ec4741a
UI adjustments
ikevin127 Nov 10, 2025
2a42e53
fixed ios native style issue
ikevin127 Nov 11, 2025
ec52bcb
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 11, 2025
ae35348
submodule sync
ikevin127 Nov 11, 2025
543a930
perf-6 improvements - ready for review
ikevin127 Nov 11, 2025
9151325
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 13, 2025
a52ea8c
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 14, 2025
1adff7a
resolved review comments
ikevin127 Nov 15, 2025
f21c079
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 15, 2025
fc60538
eslint
ikevin127 Nov 15, 2025
b088aef
resolved review comments (2)
ikevin127 Nov 17, 2025
31e28e2
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 17, 2025
0eaaa23
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 18, 2025
b8618fb
resolved review comments (3)
ikevin127 Nov 19, 2025
7b59898
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 19, 2025
b8cefad
fixed eslint import
ikevin127 Nov 19, 2025
1c774d2
resolved review comments (4)
ikevin127 Nov 20, 2025
de1a28a
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 20, 2025
950366a
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 21, 2025
3569746
eslint
ikevin127 Nov 21, 2025
7759f8f
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 25, 2025
99b70e5
resolved review comments (5)
ikevin127 Nov 25, 2025
f45d2e6
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Nov 25, 2025
532567a
addressed spacing and flicker
ikevin127 Nov 25, 2025
c502ba8
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 9, 2025
a3e136e
Revert to Option C, added 0.1% precision logic
ikevin127 Dec 9, 2025
8819b29
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 10, 2025
b154078
resolved submodule
ikevin127 Dec 10, 2025
905a9c6
resolved submodule (1)
ikevin127 Dec 10, 2025
f565927
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 10, 2025
1c7503b
added JSDoc comments to new components
ikevin127 Dec 10, 2025
5e4425e
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 11, 2025
57441de
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 12, 2025
5bd0d3f
resolve submodule
ikevin127 Dec 12, 2025
dc0ebcf
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 13, 2025
94cb4d6
addressed focus scrolling inconsistencies
ikevin127 Dec 13, 2025
cee1a11
resolved submodule
ikevin127 Dec 13, 2025
d71f378
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 15, 2025
6bc9540
onyx tab navigator refactoring
ikevin127 Dec 16, 2025
2aedb86
Merge branch 'main' of https://github.com/Expensify/App into ikevin12…
ikevin127 Dec 17, 2025
5bd8bcf
fix: navigation go back - not found
ikevin127 Dec 17, 2025
109bf29
fix: typecheck
ikevin127 Dec 17, 2025
c3df553
fix: react compiler
ikevin127 Dec 18, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/images/percent.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions src/CONST/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_',

Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/components/Icon/chunks/expensify-icons.chunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -359,6 +360,7 @@ const Expensicons = {
Paperclip,
Pause,
Pencil,
Percent,
Phone,
Pin,
Play,
Expand Down
20 changes: 12 additions & 8 deletions src/components/PercentageForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -19,36 +19,40 @@ 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<BaseTextInputRef>;
};

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

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(
(newAmount: string) => {
// 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
shouldHighlightSelectedItem = true,
shouldDisableHoverStyle = false,
setShouldDisableHoverStyle = () => {},
isPercentageMode,
ref,
}: SelectionListProps<TItem>) {
const styles = useThemeStyles();
Expand Down Expand Up @@ -354,7 +355,12 @@ function BaseSelectionListWithSections<TItem extends ListItem>({
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});
Comment on lines +358 to +363
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this logic out of this component in some way?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason to change the viewOffset here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixing scroll offset for percentage mode on native platforms.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this logic out of this component in some way?

How do you suggest we do that differently if we need to remove variables.contentHeaderHeight on native for when isPercentageMode ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as other

pendingScrollIndexRef.current = null;
},

Expand Down
Original file line number Diff line number Diff line change
@@ -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<number>(CONST.CHARACTER_WIDTH);

return (
<View style={[styles.cannotBeEditedSplitInputContainer, shouldRemoveSpacing && [styles.removeSpacing]]}>
<Text
style={[styles.optionRowAmountInput, styles.pAbsolute]}
onLayout={(event) => {
if (event.nativeEvent.layout.width === 0 && event.nativeEvent.layout.height === 0) {
return;
}
setPrefixCharacterMargin(event.nativeEvent.layout.width);
}}
>
{splitItem.currencySymbol}
</Text>
<Text
style={[styles.getSplitListItemAmountStyle(prefixCharacterMargin, contentWidth), styles.textAlignLeft]}
numberOfLines={1}
>
{convertToDisplayStringWithoutCurrency(splitItem.amount, splitItem.currency)}
</Text>
</View>
);
}

SplitAmountDisplay.displayName = 'SplitAmountDisplay';

export default SplitAmountDisplay;
Original file line number Diff line number Diff line change
@@ -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 (
<MoneyRequestAmountInput
ref={inputCallbackRef}
disabled={!splitItem.isEditable}
autoGrow={false}
amount={splitItem.amount}
currency={splitItem.currency}
prefixCharacter={splitItem.currencySymbol}
disableKeyboard={false}
isCurrencyPressable={false}
hideFocusedState={false}
hideCurrencySymbol
submitBehavior="blurAndSubmit"
formatAmountOnBlur
onAmountChange={onSplitExpenseValueChange}
prefixContainerStyle={[styles.pv0, styles.h100]}
prefixStyle={styles.lineHeightUndefined}
inputStyle={[styles.optionRowAmountInput, styles.lineHeightUndefined]}
containerStyle={[styles.textInputContainer, styles.pl2, styles.pr1]}
touchableInputWrapperStyle={[styles.ml3]}
maxLength={formattedOriginalAmount.length + 1}
contentWidth={contentWidth}
shouldApplyPaddingToContainer
shouldUseDefaultLineHeightForPrefix={false}
shouldWrapInputInContainer={false}
onFocus={focusHandler}
onBlur={onInputBlur}
/>
);
}
return (
<SplitAmountDisplay
splitItem={splitItem}
contentWidth={contentWidth}
/>
);
}

SplitAmountInput.displayName = 'SplitAmountInput';

export default SplitAmountInput;
Original file line number Diff line number Diff line change
@@ -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 (
<View style={[styles.cannotBeEditedSplitInputContainer, styles.ph0]}>
<Text
style={[styles.getSplitListItemAmountStyle(CONST.CHARACTER_WIDTH, contentWidth), styles.textAlignLeft]}
numberOfLines={1}
>
{splitItem.percentage}%
</Text>
</View>
);
}

SplitPercentageDisplay.displayName = 'SplitPercentageDisplay';

export default SplitPercentageDisplay;
Original file line number Diff line number Diff line change
@@ -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<React.SetStateAction<string | undefined>>;
/** 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 (
<PercentageForm
onInputChange={(value) => {
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 (
<SplitPercentageDisplay
splitItem={splitItem}
contentWidth={contentWidth}
/>
);
}

SplitPercentageInput.displayName = 'SplitPercentageInput';

export default SplitPercentageInput;
Loading
Loading