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