diff --git a/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt index 5c566df606eb..dd02d8aba1bd 100644 --- a/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt +++ b/android/app/src/main/java/com/expensify/chat/navbar/NavBarManagerModule.kt @@ -1,10 +1,12 @@ package com.expensify.chat.navbar +import android.content.res.Resources import androidx.core.view.WindowInsetsControllerCompat +import com.expensify.chat.R import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod -import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.bridge.UiThreadUtil class NavBarManagerModule( private val mReactContext: ReactApplicationContext, @@ -24,4 +26,18 @@ class NavBarManagerModule( } } } + + @ReactMethod + fun getType(): String { + val resources = mReactContext.resources + val resourceId = resources.getIdentifier("config_navBarInteractionMode", "integer", "android"); + if (resourceId > 0) { + val navBarInteractionMode = resources.getInteger(resourceId) + when (navBarInteractionMode) { + 0, 1 -> return "soft-keys" + 2 -> return "gesture-bar" + } + } + return "soft-keys"; + } } diff --git a/contributingGuides/FORMS.md b/contributingGuides/FORMS.md index 469281d3e86b..be769faf568e 100644 --- a/contributingGuides/FORMS.md +++ b/contributingGuides/FORMS.md @@ -68,7 +68,7 @@ Browsers use the name prop to autofill information into the input. Here's a [ref ```jsx ``` @@ -118,7 +118,7 @@ Once a user has “touched” an input, i.e. blurred the input, we will also sta All form fields will additionally be validated when the form is submitted. Although we are validating on blur this additional step is necessary to cover edge cases where forms are auto-filled or when a form is submitted by pressing enter (i.e. there will be only a ‘submit’ event and no ‘blur’ event to hook into). -The Form component takes care of validation internally and the only requirement is that we pass a validate callback prop. The validate callback takes in the input values as argument and should return an object with shape `{[inputID]: errorMessage}`. +The Form component takes care of validation internally and the only requirement is that we pass a validate callback prop. The validate callback takes in the input values as argument and should return an object with shape `{[inputID]: errorMessage}`. Here's an example for a form that has two inputs, `routingNumber` and `accountNumber`: @@ -332,10 +332,10 @@ An example of this can be seen in the [ACHContractStep](https://github.com/Expen ### Safe Area Padding Any `FormProvider.tsx` that has a button at the bottom. If the `` is inside a ``, the bottom safe area inset is handled automatically (`includeSafeAreaPaddingBottom` needs to be set to `true`, but its the default). -If you have custom requirements and can't use ``, you can use the `useStyledSafeAreaInsets()` hook: +If you have custom requirements and can't use ``, you can use the `useSafeAreaPaddings()` hook: ```jsx -const { paddingTop, paddingBottom, safeAreaPaddingBottomStyle } = useStyledSafeAreaInsets(); +const { paddingTop, paddingBottom, safeAreaPaddingBottomStyle } = useSafeAreaPaddings(); diff --git a/src/App.tsx b/src/App.tsx index 8dd2631a6b7d..3e6fc9ac2a27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import InitialURLContextProvider from './components/InitialURLContextProvider'; import {InputBlurContextProvider} from './components/InputBlurContext'; import KeyboardProvider from './components/KeyboardProvider'; import {LocaleContextProvider} from './components/LocaleContextProvider'; +import NavigationBar from './components/NavigationBar'; import OnyxProvider from './components/OnyxProvider'; import PopoverContextProvider from './components/PopoverProvider'; import {ProductTrainingContextProvider} from './components/ProductTrainingContext'; @@ -118,6 +119,7 @@ function App({url, hybridAppSettings, timestamp}: AppProps) { + diff --git a/src/CONST.ts b/src/CONST.ts index b73bc7c33846..a5542620fdf9 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1533,6 +1533,20 @@ const CONST = { LIGHT: 'light', DARK: 'dark', }, + NAVIGATION_BAR_TYPE: { + // We consider there to be no navigation bar in one of these cases: + // 1. The device has physical navigation buttons + // 2. The device uses gesture navigation without a gesture bar. + // 3. The device uses hidden (auto-hiding) soft keys. + NONE: 'none', + SOFT_KEYS: 'soft-keys', + GESTURE_BAR: 'gesture-bar', + }, + // Currently, in Android there is no native API to detect the type of navigation bar (soft keys vs. gesture). + // The navigation bar on (standard) Android devices is around 30-50dpi tall. (Samsung: 40dpi, Huawei: ~34dpi) + // To leave room to detect soft-key navigation bars on non-standard Android devices, + // we set this height threshold to 30dpi, since gesture bars will never be taller than that. (Samsung & Huawei: ~14-15dpi) + NAVIGATION_BAR_ANDROID_SOFT_KEYS_MINIMUM_HEIGHT_THRESHOLD: 30, TRANSACTION: { DEFAULT_MERCHANT: 'Expense', UNKNOWN_MERCHANT: 'Unknown Merchant', diff --git a/src/components/Attachments/AttachmentView/index.tsx b/src/components/Attachments/AttachmentView/index.tsx index 2c07863ea2e8..93729a8e0da3 100644 --- a/src/components/Attachments/AttachmentView/index.tsx +++ b/src/components/Attachments/AttachmentView/index.tsx @@ -14,7 +14,7 @@ import Text from '@components/Text'; import {usePlaybackContext} from '@components/VideoPlayerContexts/PlaybackContext'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -116,7 +116,7 @@ function AttachmentView({ const {updateCurrentlyPlayingURL} = usePlaybackContext(); const theme = useTheme(); - const {safeAreaPaddingBottomStyle} = useStyledSafeAreaInsets(); + const {safeAreaPaddingBottomStyle} = useSafeAreaPaddings(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const [loadComplete, setLoadComplete] = useState(false); diff --git a/src/components/AutoCompleteSuggestions/index.tsx b/src/components/AutoCompleteSuggestions/index.tsx index 1426e000af58..a459cc193b40 100644 --- a/src/components/AutoCompleteSuggestions/index.tsx +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -64,7 +64,7 @@ function AutoCompleteSuggestions({measureParentContainerAndReportCu const StyleUtils = useStyleUtils(); const insets = useSafeAreaInsets(); const {keyboardHeight, isKeyboardAnimatingRef} = useKeyboardState(); - const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getSafeAreaPadding(insets ?? undefined); + const {paddingBottom: bottomInset, paddingTop: topInset} = StyleUtils.getPlatformSafeAreaPadding(insets ?? undefined); useEffect(() => { const container = containerRef.current; diff --git a/src/components/BlockingViews/BlockingView.tsx b/src/components/BlockingViews/BlockingView.tsx index 3166b45f0064..6fc43cd9b236 100644 --- a/src/components/BlockingViews/BlockingView.tsx +++ b/src/components/BlockingViews/BlockingView.tsx @@ -11,6 +11,7 @@ import Lottie from '@components/Lottie'; import type DotLottieAnimation from '@components/LottieAnimations/types'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -47,6 +48,9 @@ type BaseBlockingViewProps = { /** Additional styles to apply to the container */ containerStyle?: StyleProp; + + /** Whether to add bottom safe area padding to the view. */ + addBottomSafeAreaPadding?: boolean; }; type BlockingViewIconProps = { @@ -94,7 +98,8 @@ function BlockingView({ animationWebStyle = {}, CustomSubtitle, contentFitImage, - containerStyle, + containerStyle: containerStyleProp, + addBottomSafeAreaPadding = false, }: BlockingViewProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -132,6 +137,8 @@ function BlockingView({ ); }, [styles, subtitleText, shouldEmbedLinkWithSubtitle, CustomSubtitle]); + const containerStyle = useBottomSafeSafeAreaPaddingStyle({addBottomSafeAreaPadding, style: containerStyleProp}); + return ( {!!animation && ( diff --git a/src/components/BlockingViews/FullPageNotFoundView.tsx b/src/components/BlockingViews/FullPageNotFoundView.tsx index d751d8bc666b..b9afe868878c 100644 --- a/src/components/BlockingViews/FullPageNotFoundView.tsx +++ b/src/components/BlockingViews/FullPageNotFoundView.tsx @@ -50,6 +50,9 @@ type FullPageNotFoundViewProps = { /** Whether we should display the button that opens new SearchRouter */ shouldDisplaySearchRouter?: boolean; + + /** Whether to add bottom safe area padding to the view. */ + addBottomSafeAreaPadding?: boolean; }; // eslint-disable-next-line rulesdir/no-negated-variables @@ -67,6 +70,7 @@ function FullPageNotFoundView({ shouldForceFullScreen = false, subtitleStyle, shouldDisplaySearchRouter, + addBottomSafeAreaPadding = true, }: FullPageNotFoundViewProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -93,6 +97,7 @@ function FullPageNotFoundView({ shouldShowLink={shouldShowLink} onLinkPress={onLinkPress} subtitleStyle={subtitleStyle} + addBottomSafeAreaPadding={addBottomSafeAreaPadding} /> diff --git a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx index 787752dd4e72..dc5c210b3178 100644 --- a/src/components/BlockingViews/FullPageOfflineBlockingView.tsx +++ b/src/components/BlockingViews/FullPageOfflineBlockingView.tsx @@ -6,7 +6,12 @@ import useTheme from '@hooks/useTheme'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import BlockingView from './BlockingView'; -function FullPageOfflineBlockingView({children}: ChildrenProps) { +type FullPageOfflineBlockingViewProps = ChildrenProps & { + /** Whether to add bottom safe area padding to the view. */ + addBottomSafeAreaPadding?: boolean; +}; + +function FullPageOfflineBlockingView({children, addBottomSafeAreaPadding = true}: FullPageOfflineBlockingViewProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -19,6 +24,7 @@ function FullPageOfflineBlockingView({children}: ChildrenProps) { iconColor={theme.offline} title={translate('common.youAppearToBeOffline')} subtitle={translate('common.thisFeatureRequiresInternet')} + addBottomSafeAreaPadding={addBottomSafeAreaPadding} /> ); } diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx index 5baba39107ac..fe3e28dcf50c 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -2,7 +2,7 @@ import {useIsFocused} from '@react-navigation/native'; import type {ForwardedRef} from 'react'; import React, {useCallback, useMemo, useState} from 'react'; import type {GestureResponderEvent, LayoutChangeEvent, StyleProp, TextStyle, ViewStyle} from 'react-native'; -import {ActivityIndicator, View} from 'react-native'; +import {ActivityIndicator, StyleSheet, View} from 'react-native'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; @@ -152,6 +152,12 @@ type ButtonProps = Partial & { /** The text displays under the first line */ secondLineText?: string; + + /** + * Whether the button should have a background layer in the color of theme.appBG. + * This is needed for buttons that allow content to display under them. + */ + shouldBlendOpacity?: boolean; }; type KeyboardShortcutComponentProps = Pick; @@ -255,6 +261,7 @@ function Button( isPressOnEnterActive, isNested = false, secondLineText = '', + shouldBlendOpacity = false, ...rest }: ButtonProps, ref: ForwardedRef, @@ -356,6 +363,57 @@ function Button( return textComponent; }; + const buttonStyles = useMemo>( + () => [ + styles.button, + StyleUtils.getButtonStyleWithIcon(styles, small, medium, large, !!icon, !!(text?.length > 0), shouldShowRightIcon), + success ? styles.buttonSuccess : undefined, + danger ? styles.buttonDanger : undefined, + isDisabled ? styles.buttonOpacityDisabled : undefined, + isDisabled && !danger && !success ? styles.buttonDisabled : undefined, + shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, + shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, + text && shouldShowRightIcon ? styles.alignItemsStretch : undefined, + innerStyles, + link && styles.bgTransparent, + ], + [ + StyleUtils, + danger, + icon, + innerStyles, + isDisabled, + large, + link, + medium, + shouldRemoveLeftBorderRadius, + shouldRemoveRightBorderRadius, + shouldShowRightIcon, + small, + styles, + success, + text, + ], + ); + + const buttonContainerStyles = useMemo>( + () => [buttonStyles, shouldBlendOpacity && styles.buttonBlendContainer], + [buttonStyles, shouldBlendOpacity, styles.buttonBlendContainer], + ); + + const buttonBlendForegroundStyle = useMemo>(() => { + if (!shouldBlendOpacity) { + return undefined; + } + + const {backgroundColor, opacity} = StyleSheet.flatten(buttonStyles); + + return { + backgroundColor, + opacity, + }; + }, [buttonStyles, shouldBlendOpacity]); + return ( <> {pressOnEnter && ( @@ -402,6 +460,7 @@ function Button( onPressIn={onPressIn} onPressOut={onPressOut} onMouseDown={onMouseDown} + shouldBlendOpacity={shouldBlendOpacity} disabled={isLoading || isDisabled} wrapperStyle={[ isDisabled ? {...styles.cursorDisabled, ...styles.noSelect} : {}, @@ -410,19 +469,7 @@ function Button( shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, style, ]} - style={[ - styles.button, - StyleUtils.getButtonStyleWithIcon(styles, small, medium, large, !!icon, !!(text?.length > 0), shouldShowRightIcon), - success ? styles.buttonSuccess : undefined, - danger ? styles.buttonDanger : undefined, - isDisabled ? styles.buttonOpacityDisabled : undefined, - isDisabled && !danger && !success ? styles.buttonDisabled : undefined, - shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, - shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - text && shouldShowRightIcon ? styles.alignItemsStretch : undefined, - innerStyles, - link && styles.bgTransparent, - ]} + style={buttonContainerStyles} isNested={isNested} hoverStyle={[ shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined, @@ -439,6 +486,7 @@ function Button( onHoverIn={() => setIsHovered(true)} onHoverOut={() => setIsHovered(false)} > + {shouldBlendOpacity && } {renderContent()} {isLoading && ( > & { /** Label for the search text input */ searchInputLabel: string; diff --git a/src/components/EmptyStateComponent/index.tsx b/src/components/EmptyStateComponent/index.tsx index 058b2e564f89..d3a715ad8748 100644 --- a/src/components/EmptyStateComponent/index.tsx +++ b/src/components/EmptyStateComponent/index.tsx @@ -29,6 +29,7 @@ function EmptyStateComponent({ lottieWebViewStyles, showsVerticalScrollIndicator, minModalHeight = 400, + addBottomSafeAreaPadding = false, }: EmptyStateComponentProps) { const styles = useThemeStyles(); const [videoAspectRatio, setVideoAspectRatio] = useState(VIDEO_ASPECT_RATIO); @@ -88,6 +89,7 @@ function EmptyStateComponent({ showsVerticalScrollIndicator={showsVerticalScrollIndicator} contentContainerStyle={[{minHeight: minModalHeight}, styles.flexGrow1, styles.flexShrink0, containerStyles]} style={styles.flex1} + addBottomSafeAreaPadding={addBottomSafeAreaPadding} > = { lottieWebViewStyles?: React.CSSProperties | undefined; minModalHeight?: number; showsVerticalScrollIndicator?: boolean; + + /** Whether to add bottom safe area padding to the view. */ + addBottomSafeAreaPadding?: boolean; }; type MediaType = SharedProps & { diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx index 90adaec7a27e..fe9179fa88b4 100644 --- a/src/components/FixedFooter.tsx +++ b/src/components/FixedFooter.tsx @@ -1,7 +1,8 @@ import type {ReactNode} from 'react'; -import React from 'react'; +import React, {useMemo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; +import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useThemeStyles from '@hooks/useThemeStyles'; type FixedFooterProps = { @@ -10,16 +11,41 @@ type FixedFooterProps = { /** Styles to be assigned to Container */ style?: StyleProp; + + /** Whether to add bottom safe area padding to the content. */ + addBottomSafeAreaPadding?: boolean; + + /** Whether to stick the footer to the bottom of the screen. */ + shouldStickToBottom?: boolean; }; -function FixedFooter({style, children}: FixedFooterProps) { +function FixedFooter({style, children, addBottomSafeAreaPadding = false, shouldStickToBottom = false}: FixedFooterProps) { const styles = useThemeStyles(); + const {paddingBottom} = useSafeAreaPaddings(true); + + const footerStyle = useMemo>(() => { + const totalPaddingBottom = styles.pb5.paddingBottom + paddingBottom; + + // If the footer should stick to the bottom, we use absolute positioning instead of flex. + // In this case, we need to use style.bottom instead of style.paddingBottom. + if (shouldStickToBottom) { + return {position: 'absolute', left: 0, right: 0, bottom: addBottomSafeAreaPadding ? totalPaddingBottom : styles.pb5.paddingBottom}; + } + + // If the footer should not stick to the bottom, we use flex and add the safe area padding in styles.paddingBottom. + if (addBottomSafeAreaPadding) { + return {paddingBottom: totalPaddingBottom}; + } + + // Otherwise, we just use the default bottom padding. + return styles.pb5; + }, [addBottomSafeAreaPadding, paddingBottom, shouldStickToBottom, styles.pb5]); if (!children) { return null; } - return {children}; + return {children}; } FixedFooter.displayName = 'FixedFooter'; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index bf3746b61776..6c8d3f989e16 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -75,6 +75,12 @@ type FormProviderProps = FormProps(null); const formContentRef = useRef(null); @@ -103,13 +110,78 @@ function FormWrapper({ focusInput?.focus?.(); }, [errors, formState?.errorFields, inputRefs]); + const {paddingBottom} = useSafeAreaPaddings(true); + const SubmitButton = useMemo( + () => + isSubmitButtonVisible && ( + + ), + [ + disablePressOnEnter, + enabledWhenOffline, + errorMessage, + errors, + footerContent, + formState?.errorFields, + formState?.isLoading, + isLoading, + isSubmitActionDangerous, + isSubmitButtonVisible, + isSubmitDisabled, + onFixTheErrorsLinkPressed, + onSubmit, + paddingBottom, + shouldHideFixErrorsAlert, + shouldSubmitButtonStickToBottom, + style, + styles.flex1, + styles.mh0, + styles.mt5, + styles.pb5.paddingBottom, + submitButtonStyles, + submitButtonText, + submitFlexEnabled, + ], + ); + const scrollViewContent = useCallback( () => ( { if (!shouldScrollToEnd) { return; @@ -122,77 +194,50 @@ function FormWrapper({ }} > {children} - {isSubmitButtonVisible && ( - - )} + {!shouldSubmitButtonStickToBottom && SubmitButton} ), - [ - formID, - style, - safeAreaInsetPaddingBottom, - styles.pb5.paddingBottom, - styles.mh0, - styles.mt5, - styles.flex1, - children, - isSubmitButtonVisible, - submitButtonText, - isSubmitDisabled, - errors, - formState?.errorFields, - formState?.isLoading, - shouldHideFixErrorsAlert, - errorMessage, - isLoading, - onSubmit, - footerContent, - onFixTheErrorsLinkPressed, - submitFlexEnabled, - submitButtonStyles, - enabledWhenOffline, - isSubmitActionDangerous, - disablePressOnEnter, - shouldScrollToEnd, - ], + [formID, style, styles.pb5, children, shouldSubmitButtonStickToBottom, SubmitButton, shouldScrollToEnd], ); if (!shouldUseScrollView) { + if (shouldSubmitButtonStickToBottom) { + return ( + <> + {scrollViewContent()} + {SubmitButton} + + ); + } + return scrollViewContent(); } - return scrollContextEnabled ? ( - - {scrollViewContent()} - - ) : ( - - {scrollViewContent()} - + return ( + + {scrollContextEnabled ? ( + + {scrollViewContent()} + + ) : ( + + {scrollViewContent()} + + )} + {shouldSubmitButtonStickToBottom && SubmitButton} + ); } diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx index fabb5e54cb60..3eb0b0abd508 100644 --- a/src/components/FormAlertWithSubmitButton.tsx +++ b/src/components/FormAlertWithSubmitButton.tsx @@ -62,6 +62,12 @@ type FormAlertWithSubmitButtonProps = { /** The priority to assign the enter key event listener to buttons. 0 is the highest priority. */ enterKeyEventListenerPriority?: number; + + /** + * Whether the button should have a background layer in the color of theme.appBG. + * This is needed for buttons that allow content to display under them. + */ + shouldBlendOpacity?: boolean; }; function FormAlertWithSubmitButton({ @@ -83,6 +89,7 @@ function FormAlertWithSubmitButton({ useSmallerSubmitButtonSize = false, errorMessageStyle, enterKeyEventListenerPriority = 0, + shouldBlendOpacity = false, }: FormAlertWithSubmitButtonProps) { const styles = useThemeStyles(); const style = [!footerContent ? {} : styles.mb3, buttonStyles]; @@ -107,6 +114,7 @@ function FormAlertWithSubmitButton({ {isOffline && !enabledWhenOffline ? (