diff --git a/assets/images/camera.svg b/assets/images/camera.svg index 49b23e5c7599..71a657102e04 100644 --- a/assets/images/camera.svg +++ b/assets/images/camera.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/assets/images/plus-circle.svg b/assets/images/plus-circle.svg new file mode 100644 index 000000000000..b840d90b1073 --- /dev/null +++ b/assets/images/plus-circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index c6ef456681f7..13cb871d6485 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -1,15 +1,21 @@ import type {ForwardedRef} from 'react'; import React, {useEffect, useRef} from 'react'; // eslint-disable-next-line no-restricted-imports -import type {GestureResponderEvent, Role, Text, View} from 'react-native'; +import type {GestureResponderEvent, Role, Text as TextType, View as ViewType} from 'react-native'; +import {View} from 'react-native'; import Animated, {Easing, interpolateColor, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import Svg, {Path} from 'react-native-svg'; +import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import variables from '@styles/variables'; -import {PressableWithoutFeedback} from './Pressable'; +import CONST from '@src/CONST'; +import Icon from './Icon'; +import {PlusCircle} from './Icon/Expensicons'; +import {PressableWithFeedback, PressableWithoutFeedback} from './Pressable'; +import Text from './Text'; const FAB_PATH = 'M12,3c0-1.1-0.9-2-2-2C8.9,1,8,1.9,8,3v5H3c-1.1,0-2,0.9-2,2c0,1.1,0.9,2,2,2h5v5c0,1.1,0.9,2,2,2c1.1,0,2-0.9,2-2v-5h5c1.1,0,2-0.9,2-2c0-1.1-0.9-2-2-2h-5V3z'; const SMALL_FAB_PATH = @@ -35,16 +41,17 @@ type FloatingActionButtonProps = { role: Role; /** Reference to the outer element */ - ref?: ForwardedRef; + ref?: ForwardedRef; }; function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabel, role, ref}: FloatingActionButtonProps) { - const {success, successHover, buttonDefaultBG, textLight} = useTheme(); + const {buttonDefaultBG, buttonHoveredBG, icon} = useTheme(); const styles = useThemeStyles(); const borderRadius = styles.floatingActionButton.borderRadius; - const fabPressable = useRef(null); + const fabPressable = useRef(null); const {shouldUseNarrowLayout} = useResponsiveLayout(); const isLHBVisible = !shouldUseNarrowLayout; + const {translate} = useLocalize(); const fabSize = isLHBVisible ? variables.iconSizeSmall : variables.iconSizeNormal; @@ -61,7 +68,7 @@ function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabe }, [isActive, sharedValue]); const animatedStyle = useAnimatedStyle(() => { - const backgroundColor = interpolateColor(sharedValue.get(), [0, 1], [success, buttonDefaultBG]); + const backgroundColor = interpolateColor(sharedValue.get(), [0, 1], [buttonDefaultBG, buttonHoveredBG]); return { transform: [{rotate: `${sharedValue.get() * 135}deg`}], @@ -85,45 +92,84 @@ function FloatingActionButton({onPress, onLongPress, isActive, accessibilityLabe onLongPress?.(event); }; + if (isLHBVisible) { + return ( + { + fabPressable.current = el ?? null; + if (buttonRef && 'current' in buttonRef) { + buttonRef.current = el ?? null; + } + }} + style={[ + styles.navigationTabBarFABItem, + + // Prevent text selection on touch devices (e.g. on long press) + canUseTouchScreen() && styles.userSelectNone, + styles.flex1, + ]} + accessibilityLabel={accessibilityLabel} + onPress={toggleFabAction} + onLongPress={longPressFabAction} + role={role} + shouldUseHapticsOnLongPress + testID="floating-action-button" + > + {({hovered}) => ( + + + + + + )} + + ); + } + return ( - { - fabPressable.current = el ?? null; - if (buttonRef && 'current' in buttonRef) { - buttonRef.current = el ?? null; - } - }} + - {({hovered}) => ( - + + + + - - - - - )} - + {translate('common.create')} + + + ); } diff --git a/src/components/FloatingCameraButton.tsx b/src/components/FloatingCameraButton.tsx new file mode 100644 index 000000000000..821cc0cc4c44 --- /dev/null +++ b/src/components/FloatingCameraButton.tsx @@ -0,0 +1,91 @@ +import React, {useMemo} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {startMoneyRequest} from '@libs/actions/IOU'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import interceptAnonymousUser from '@libs/interceptAnonymousUser'; +import Navigation from '@libs/Navigation/Navigation'; +import {generateReportID, getWorkspaceChats} from '@libs/ReportUtils'; +import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import Icon from './Icon'; +import {Camera} from './Icon/Expensicons'; +import {PressableWithoutFeedback} from './Pressable'; + +const sessionSelector = (session: OnyxEntry) => ({email: session?.email, accountID: session?.accountID}); + +function FloatingCameraButton() { + const {textLight} = useTheme(); + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [activePolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${activePolicyID}`, {canBeMissing: true}); + const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false, selector: sessionSelector}); + const [allTransactionDrafts] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_DRAFT, {canBeMissing: true}); + const reportID = useMemo(() => generateReportID(), []); + + const [policyChatForActivePolicy] = useOnyx(ONYXKEYS.COLLECTION.REPORT, { + canBeMissing: true, + selector: (reports) => { + if (isEmptyObject(activePolicy) || !activePolicy?.isPolicyExpenseChatEnabled) { + return undefined; + } + const policyChatsForActivePolicy = getWorkspaceChats(activePolicyID, [session?.accountID ?? CONST.DEFAULT_NUMBER_ID], reports); + return policyChatsForActivePolicy.at(0); + }, + }); + + const onPress = () => { + interceptAnonymousUser(() => { + if (policyChatForActivePolicy?.policyID && shouldRestrictUserBillableActions(policyChatForActivePolicy.policyID)) { + Navigation.navigate(ROUTES.RESTRICTED_ACTION.getRoute(policyChatForActivePolicy.policyID)); + return; + } + + const quickActionReportID = policyChatForActivePolicy?.reportID ?? reportID; + startMoneyRequest(CONST.IOU.TYPE.SUBMIT, quickActionReportID, CONST.IOU.REQUEST_TYPE.SCAN, true, undefined, allTransactionDrafts); + }); + }; + + return ( + + + + + + ); +} + +FloatingCameraButton.displayName = 'FloatingCameraButton'; + +export default FloatingCameraButton; diff --git a/src/components/FloatingReceiptButton.tsx b/src/components/FloatingReceiptButton.tsx new file mode 100644 index 000000000000..ea10344b6b77 --- /dev/null +++ b/src/components/FloatingReceiptButton.tsx @@ -0,0 +1,72 @@ +import React, {useRef} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {GestureResponderEvent, Role, Text, View as ViewType} from 'react-native'; +import {View} from 'react-native'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import variables from '@styles/variables'; +import Icon from './Icon'; +import {ReceiptPlus} from './Icon/Expensicons'; +import {PressableWithoutFeedback} from './Pressable'; + +type FloatingReceiptButtonProps = { + /* Callback to fire on request to toggle the FloatingReceiptButton */ + onPress: (event: GestureResponderEvent | KeyboardEvent | undefined) => void; + + /* An accessibility label for the button */ + accessibilityLabel: string; + + /* An accessibility role for the button */ + role: Role; +}; + +function FloatingReceiptButton({onPress, accessibilityLabel, role}: FloatingReceiptButtonProps) { + const {successHover, textLight} = useTheme(); + const styles = useThemeStyles(); + const borderRadius = styles.floatingActionButton.borderRadius; + const fabPressable = useRef(null); + + const toggleFabAction = (event: GestureResponderEvent | KeyboardEvent | undefined) => { + // Drop focus to avoid blue focus ring. + fabPressable.current?.blur(); + onPress(event); + }; + + return ( + { + fabPressable.current = el ?? null; + }} + style={[ + styles.navigationTabBarFABItem, + + // Prevent text selection on touch devices (e.g. on long press) + canUseTouchScreen() && styles.userSelectNone, + ]} + accessibilityLabel={accessibilityLabel} + onPress={toggleFabAction} + role={role} + shouldUseHapticsOnLongPress + testID="floating-receipt-button" + > + {({hovered}) => ( + + + + )} + + ); +} + +FloatingReceiptButton.displayName = 'FloatingReceiptButton'; + +export default FloatingReceiptButton; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 91bc0a1207c7..1a885a21901c 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -161,6 +161,7 @@ import Phone from '@assets/images/phone.svg'; import Pin from '@assets/images/pin.svg'; import Plane from '@assets/images/plane.svg'; import Play from '@assets/images/play.svg'; +import PlusCircle from '@assets/images/plus-circle.svg'; import PlusMinus from '@assets/images/plus-minus.svg'; import Plus from '@assets/images/plus.svg'; import Printer from '@assets/images/printer.svg'; @@ -452,4 +453,5 @@ export { SageIntacctExport, XeroExport, ArrowCircleClockwise, + PlusCircle, }; diff --git a/src/components/Navigation/NavigationTabBar/index.tsx b/src/components/Navigation/NavigationTabBar/index.tsx index 918de5f18868..cc7d66003514 100644 --- a/src/components/Navigation/NavigationTabBar/index.tsx +++ b/src/components/Navigation/NavigationTabBar/index.tsx @@ -324,7 +324,7 @@ function NavigationTabBar({selectedTab, isTopLevelBar = false}: NavigationTabBar onPress={navigateToSettings} /> - + diff --git a/src/components/Navigation/TopLevelNavigationTabBar/index.tsx b/src/components/Navigation/TopLevelNavigationTabBar/index.tsx index c3c3be75e349..981b1676d9b3 100644 --- a/src/components/Navigation/TopLevelNavigationTabBar/index.tsx +++ b/src/components/Navigation/TopLevelNavigationTabBar/index.tsx @@ -1,13 +1,16 @@ import type {ParamListBase} from '@react-navigation/native'; import React, {useContext, useEffect, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; +import FloatingCameraButton from '@components/FloatingCameraButton'; import {FullScreenBlockingViewContext} from '@components/FullScreenBlockingViewContextProvider'; import NavigationTabBar from '@components/Navigation/NavigationTabBar'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSafeAreaPaddings from '@hooks/useSafeAreaPaddings'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import getPlatform from '@libs/getPlatform'; import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; +import CONST from '@src/CONST'; import getIsNavigationTabBarVisibleDirectly from './getIsNavigationTabBarVisibleDirectly'; import getIsScreenWithNavigationTabBarFocused from './getIsScreenWithNavigationTabBarFocused'; import getSelectedTab from './getSelectedTab'; @@ -43,6 +46,10 @@ function TopLevelNavigationTabBar({state}: TopLevelNavigationTabBarProps) { const isReadyToDisplayBottomBar = isAfterClosingTransition && shouldDisplayBottomBar && !isBlockingViewVisible; const shouldDisplayLHB = !shouldUseNarrowLayout; + const platform = getPlatform(true); + // We want to display the floating camera button on mobile devices (both web and native) + const shouldShowFloatingCameraButton = platform !== CONST.PLATFORM.WEB && platform !== CONST.PLATFORM.DESKTOP; + useEffect(() => { if (!shouldDisplayBottomBar) { // If the bottom tab is not visible, that means there is a screen covering it. @@ -74,6 +81,7 @@ function TopLevelNavigationTabBar({state}: TopLevelNavigationTabBarProps) { selectedTab={selectedTab} isTopLevelBar /> + {shouldShowFloatingCameraButton && } ); } diff --git a/src/components/Search/SearchPageFooter.tsx b/src/components/Search/SearchPageFooter.tsx index 7f428e162a7f..c2f4f5d62097 100644 --- a/src/components/Search/SearchPageFooter.tsx +++ b/src/components/Search/SearchPageFooter.tsx @@ -3,6 +3,7 @@ import {View} from 'react-native'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -21,10 +22,22 @@ function SearchPageFooter({count, total, currency}: SearchPageFooterProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const valueTextStyle = useMemo(() => (isOffline ? [styles.textLabelSupporting, styles.labelStrong] : [styles.labelStrong]), [isOffline, styles]); return ( - + {`${translate('common.expenses')}:`} {count} diff --git a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx b/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx index 674d819e42b9..96d94f250193 100644 --- a/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx +++ b/src/components/Search/SearchPageHeader/SearchTypeMenuPopover.tsx @@ -28,7 +28,7 @@ function SearchTypeMenuPopover({queryJSON}: SearchTypeMenuNarrowProps) { + + {!shouldUseNarrowLayout && ( + + )} paddingHorizontal: 4, }, + navigationTabBarFABItem: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 4, + }, + /** * Background style applied to navigation tab bar items when they are hovered. * Do not apply for the active/selected state, those already have their own styling. @@ -695,6 +702,13 @@ const staticStyles = (theme: ThemeColors) => paddingHorizontal: 4, }, + leftNavigationTabBarFAB: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 4, + }, + button: { backgroundColor: theme.buttonDefaultBG, borderRadius: variables.buttonBorderRadius, @@ -1574,6 +1588,13 @@ const staticStyles = (theme: ThemeColors) => height: variables.componentSizeNormal, }, + floatingCameraButton: { + position: 'absolute', + top: -variables.componentSizeLarge - 16, + right: 16, + zIndex: 10, + }, + topBarLabel: { color: theme.text, fontSize: variables.fontSizeXLarge, @@ -5487,6 +5508,14 @@ const dynamicStyles = (theme: ThemeColors) => }) satisfies ViewStyle, createMenuPositionSidebar: (windowHeight: number) => + ({ + horizontal: 16, + // Menu should be displayed 8px above the floating action button. + // To achieve that sidebar must be moved by: distance from the bottom of the sidebar to the fab (16px) + fab height on a wide layout (variables.componentSizeNormal) + distance above the fab (8px) + vertical: windowHeight - 16 - variables.componentSizeNormal - 8, + }) satisfies AnchorPosition, + + createMenuPositionSearchBar: (windowHeight: number) => ({ horizontal: 18, // Menu should be displayed 12px above the floating action button. diff --git a/tests/ui/FloatingActionButtonTest.tsx b/tests/ui/FloatingActionButtonTest.tsx index 61130959a852..2b32dafc0f48 100644 --- a/tests/ui/FloatingActionButtonTest.tsx +++ b/tests/ui/FloatingActionButtonTest.tsx @@ -54,14 +54,14 @@ describe('FloatingActionButton hover', () => { const animatedContainer = screen.getByTestId('fab-animated-container'); // Before hover, should not have successHover background - expect(animatedContainer).not.toHaveStyle({backgroundColor: colors.greenHover}); + expect(animatedContainer).not.toHaveStyle({backgroundColor: colors.productDark500}); // Test hover in fireEvent(fab, 'hoverIn'); - expect(animatedContainer).toHaveStyle({backgroundColor: colors.greenHover}); + expect(animatedContainer).toHaveStyle({backgroundColor: colors.productDark500}); // Test hover out fireEvent(fab, 'hoverOut'); - expect(animatedContainer).not.toHaveStyle({backgroundColor: colors.greenHover}); + expect(animatedContainer).not.toHaveStyle({backgroundColor: colors.productDark500}); }); });