diff --git a/src/CONST/index.ts b/src/CONST/index.ts index cec1f874bb2cd..aaa3b2eeb358e 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -247,6 +247,7 @@ const CONST = { POPOVER_MENU_MAX_HEIGHT: 496, POPOVER_MENU_MAX_HEIGHT_MOBILE: 432, POPOVER_DATE_WIDTH: 338, + POPOVER_DATE_RANGE_WIDTH: 672, POPOVER_DATE_MAX_HEIGHT: 366, POPOVER_DATE_MIN_HEIGHT: 322, TOOLTIP_ANIMATION_DURATION: 500, @@ -7728,6 +7729,7 @@ const CONST = { EQUAL_TO: 'eq', CONTAINS: 'contains', NOT_EQUAL_TO: 'neq', + RANGE: 'range', GREATER_THAN: 'gt', GREATER_THAN_OR_EQUAL_TO: 'gte', LOWER_THAN: 'lt', @@ -7802,6 +7804,7 @@ const CONST = { ON_PREFIX: 'reportFieldOn-', AFTER_PREFIX: 'reportFieldAfter-', BEFORE_PREFIX: 'reportFieldBefore-', + RANGE_PREFIX: 'reportFieldRange-', }, TAG_EMPTY_VALUE: 'none', CATEGORY_EMPTY_VALUE: 'none', @@ -7931,11 +7934,16 @@ const CONST = { ON: 'On', AFTER: 'After', BEFORE: 'Before', + RANGE: 'Range', + }, + get CUSTOM_DATE_MODIFIERS() { + return [this.DATE_MODIFIERS.ON, this.DATE_MODIFIERS.BEFORE, this.DATE_MODIFIERS.AFTER] as const; }, DATE_FILTER_SUB_PAGE: { ON: 'on', AFTER: 'after', BEFORE: 'before', + RANGE: 'range', }, AMOUNT_MODIFIERS: { LESS_THAN: 'LessThan', diff --git a/src/components/DatePicker/CalendarPicker/Day.tsx b/src/components/DatePicker/CalendarPicker/Day.tsx index 3db884bb7fdc0..f9f45c0b533ab 100644 --- a/src/components/DatePicker/CalendarPicker/Day.tsx +++ b/src/components/DatePicker/CalendarPicker/Day.tsx @@ -2,6 +2,7 @@ import React from 'react'; import {View} from 'react-native'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import getButtonState from '@libs/getButtonState'; @@ -23,17 +24,32 @@ type DayProps = { }; function Day({disabled, selected, pressed, hovered, children}: DayProps) { + const theme = useTheme(); const themeStyles = useThemeStyles(); const StyleUtils = useStyleUtils(); return ( - {children} + + {children} + ); } diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index b520650c8c74c..26c0be13e2936 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -1,6 +1,7 @@ import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -35,6 +36,9 @@ type CalendarPickerProps = { /** A function called when the date is selected */ onSelected?: (selectedDate: string) => void; + + /** Optional style override for the header container */ + headerContainerStyle?: StyleProp; }; function getInitialCurrentDateView(value: Date | string, minDate: Date, maxDate: Date) { @@ -56,6 +60,7 @@ function CalendarPicker({ onSelected, DayComponent = Day, selectableDates, + headerContainerStyle, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -104,11 +109,9 @@ function CalendarPicker({ * @param day - The day of the month that was selected. */ const onDayPressed = (day: number) => { - setCurrentDateView((prev) => { - const newCurrentDateView = setDate(new Date(prev), day); - onSelected?.(format(new Date(newCurrentDateView), CONST.DATE.FNS_FORMAT_STRING)); - return newCurrentDateView; - }); + const newCurrentDateView = setDate(new Date(currentDateView), day); + setCurrentDateView(newCurrentDateView); + onSelected?.(format(newCurrentDateView, CONST.DATE.FNS_FORMAT_STRING)); }; /** @@ -175,13 +178,19 @@ function CalendarPicker({ const webOnlyMarginStyle = isSmallScreenWidth ? {} : styles.mh1; const calendarContainerStyle = isSmallScreenWidth ? [webOnlyMarginStyle, themeStyles.calendarBodyContainer] : [webOnlyMarginStyle, animatedStyle]; + const headerPaddingStyle = headerContainerStyle ?? themeStyles.ph5; + // On mobile (isSmallScreenWidth is always true on native), the height animation is skipped + // so using Animated.View is unnecessary. Using a plain View with collapsable={false} avoids + // activating Reanimated's Fabric commit hook, which on Android can interfere with React's + // reconciliation of child view styles and prevent day-selection background changes from painting. + const CalendarBody = isSmallScreenWidth ? View : Animated.View; const getAccessibilityState = useCallback((isSelected: boolean) => ({selected: isSelected}), []); return ( ))} - + {calendarDaysMatrix?.map((week) => ( {week.map((day, index) => { @@ -311,7 +324,7 @@ function CalendarPicker({ })} ))} - + SearchDateValues; - /** Handles back navigation — closes date modifier if open, otherwise calls onBackButtonPress */ + /** Handles back navigation by closing the active date modifier before leaving the screen */ goBack: () => void; }; @@ -39,15 +42,15 @@ type DateFilterBaseProps = { onSelectDateModifier?: (dateModifier: SearchDateModifier | null) => void; /** Callback when the date modifier screen is opened or closed (on/after/before) */ onDateModifierChange?: (isOpen: boolean) => void; - /** If true, the Reset/Save buttons are only shown when a date modifier (On/After/Before) is selected. Defaults to false (always show buttons). */ + /** If true, the Reset/Save buttons are only shown when a date modifier is selected. */ shouldShowButtonsOnlyWithDateModifier?: boolean; - /** Whether to render the built-in HeaderWithBackButton. Defaults to true. Set to false when the parent provides its own header. */ + /** Whether to render the built-in HeaderWithBackButton. Defaults to true. */ shouldShowHeader?: boolean; /** The ref handle */ ref?: React.Ref; }; -// Component uses ref as a prop, which is supported in modern React +// Component uses ref as a prop, which is supported in modern React. function DateFilterBase({ title, defaultDateValues, @@ -66,63 +69,87 @@ function DateFilterBase({ const styles = useThemeStyles(); const {translate} = useLocalize(); + const normalizedDefaultDateValues = useMemo(() => ({...getEmptyDateValues(), ...defaultDateValues}), [defaultDateValues]); const searchDatePresetFilterBaseRef = useRef(null); const [selectedDateModifierState, setSelectedDateModifierState] = useState(null); + const [shouldShowRangeError, setShouldShowRangeError] = useState(false); + const [rangeDisplayText, setRangeDisplayText] = useState(() => + getDateRangeDisplayValueFromFormValue( + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE], + ), + ); + + useEffect(() => { + setRangeDisplayText( + getDateRangeDisplayValueFromFormValue( + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.RANGE], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.AFTER], + normalizedDefaultDateValues[CONST.SEARCH.DATE_MODIFIERS.BEFORE], + ), + ); + }, [normalizedDefaultDateValues]); + + const handleDateValuesChange = useCallback( + (values: SearchDateValues) => { + setRangeDisplayText( + getDateRangeDisplayValueFromFormValue(values[CONST.SEARCH.DATE_MODIFIERS.RANGE], values[CONST.SEARCH.DATE_MODIFIERS.AFTER], values[CONST.SEARCH.DATE_MODIFIERS.BEFORE]), + ); + onDateValuesChange?.(values); + }, + [onDateValuesChange], + ); const isDateModifierControlled = selectedDateModifierProp !== undefined; const selectedDateModifier = isDateModifierControlled ? selectedDateModifierProp : selectedDateModifierState; - const setSelectedDateModifier = (dateModifier: SearchDateModifier | null) => { - if (isDateModifierControlled) { - onSelectDateModifier?.(dateModifier); - return; - } + const setSelectedDateModifier = useCallback( + (dateModifier: SearchDateModifier | null) => { + if (isDateModifierControlled) { + onSelectDateModifier?.(dateModifier); + return; + } + setSelectedDateModifierState(dateModifier); + }, + [isDateModifierControlled, onSelectDateModifier], + ); - setSelectedDateModifierState(dateModifier); - }; - - const handleSelectDateModifier = (dateModifier: SearchDateModifier | null) => { - setSelectedDateModifier(dateModifier); - onDateModifierChange?.(!!dateModifier); - if (onDateValuesChange) { - const values = searchDatePresetFilterBaseRef.current?.getDateValues() ?? { - [CONST.SEARCH.DATE_MODIFIERS.ON]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.AFTER]: undefined, - }; - onDateValuesChange(values); - } - }; + const handleSelectDateModifier = useCallback( + (dateModifier: SearchDateModifier | null) => { + setSelectedDateModifier(dateModifier); + onDateModifierChange?.(!!dateModifier); + onDateValuesChange?.(searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues()); + }, + [onDateModifierChange, onDateValuesChange, setSelectedDateModifier], + ); - const goBack = () => { + const goBack = useCallback(() => { if (selectedDateModifier) { + if (selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE) { + searchDatePresetFilterBaseRef.current?.restoreRangeToEntrySnapshot(); + } setSelectedDateModifier(null); + setShouldShowRangeError(false); onDateModifierChange?.(false); return; } onBackButtonPress?.(); - }; - - useImperativeHandle(ref, () => ({ - getDateValues: () => - searchDatePresetFilterBaseRef.current?.getDateValues() ?? { - [CONST.SEARCH.DATE_MODIFIERS.ON]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.BEFORE]: undefined, - [CONST.SEARCH.DATE_MODIFIERS.AFTER]: undefined, - }, - goBack, - })); - - function getComputedTitle() { - if (selectedDateModifier) { - return translate(`common.${selectedDateModifier.toLowerCase() as SearchDateModifierLower}`); - } + }, [onBackButtonPress, onDateModifierChange, selectedDateModifier, setSelectedDateModifier]); + + useImperativeHandle( + ref, + () => ({ + getDateValues: () => searchDatePresetFilterBaseRef.current?.getDateValues() ?? getEmptyDateValues(), + goBack, + }), + [goBack], + ); - return title; - } + const computedTitle = getDateModifierTitle(selectedDateModifier, title ?? '', translate); - const reset = () => { + const reset = useCallback(() => { if (!searchDatePresetFilterBaseRef.current) { return; } @@ -130,51 +157,72 @@ function DateFilterBase({ if (selectedDateModifier) { searchDatePresetFilterBaseRef.current.clearDateValueOfSelectedDateModifier(); setSelectedDateModifier(null); + setShouldShowRangeError(false); onDateModifierChange?.(false); return; } searchDatePresetFilterBaseRef.current.clearDateValues(); - }; + setShouldShowRangeError(false); + }, [onDateModifierChange, selectedDateModifier, setSelectedDateModifier]); - const save = () => { + const save = useCallback(() => { if (!searchDatePresetFilterBaseRef.current) { return; } if (selectedDateModifier) { + if (!searchDatePresetFilterBaseRef.current.validate()) { + return; + } + searchDatePresetFilterBaseRef.current.setDateValueOfSelectedDateModifier(); + const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); setSelectedDateModifier(null); + setShouldShowRangeError(false); onDateModifierChange?.(false); + onSubmit(dateValues); return; } - const dateValues = searchDatePresetFilterBaseRef.current.getDateValues(); - onSubmit(dateValues); - }; + onSubmit(searchDatePresetFilterBaseRef.current.getDateValues()); + }, [onDateModifierChange, onSubmit, selectedDateModifier, setSelectedDateModifier]); - const computedTitle = getComputedTitle(); + const shouldShowActionButtons = !shouldShowButtonsOnlyWithDateModifier || !!selectedDateModifier; + const shouldShowRangeSummary = selectedDateModifier === CONST.SEARCH.DATE_MODIFIERS.RANGE && !!rangeDisplayText; - const content = ( - <> + return ( + {shouldShowHeader && ( )} - + + {shouldShowRangeSummary && ( + + {`${translate('common.range')}: `} + {rangeDisplayText} + + )} - {(!shouldShowButtonsOnlyWithDateModifier || !!selectedDateModifier) && ( + {shouldShowActionButtons && ( <>