From 8162eaeb8aa9e32194249b79530ae9c7dd316b46 Mon Sep 17 00:00:00 2001 From: Mounir Dhahri Date: Fri, 22 Nov 2024 13:46:48 +0100 Subject: [PATCH] feat: simplify bottom tabs logic (#11175) * feat: simplify bottom tabs logic * chore: better comment * chore: remove old visual clue * chore: make text non selectable --- .../Navigation/AuthenticatedRoutes/Tabs.tsx | 26 ++++- .../Utils/useBottomTabsBadges.tests.ts | 97 +++++++++++++++++++ .../Navigation/Utils/useBottomTabsBadges.ts | 90 +++++++++++++++++ .../Scenes/BottomTabs/bottomTabsConfig.tsx | 1 + 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 src/app/Navigation/Utils/useBottomTabsBadges.tests.ts create mode 100644 src/app/Navigation/Utils/useBottomTabsBadges.ts diff --git a/src/app/Navigation/AuthenticatedRoutes/Tabs.tsx b/src/app/Navigation/AuthenticatedRoutes/Tabs.tsx index cf565505349..4fa416e2bad 100644 --- a/src/app/Navigation/AuthenticatedRoutes/Tabs.tsx +++ b/src/app/Navigation/AuthenticatedRoutes/Tabs.tsx @@ -1,4 +1,6 @@ import { tappedTabBar } from "@artsy/cohesion" +import { Text } from "@artsy/palette-mobile" +import { THEME } from "@artsy/palette-tokens" import { createBottomTabNavigator } from "@react-navigation/bottom-tabs" import { createNativeStackNavigator } from "@react-navigation/native-stack" import { AppModule, modules } from "app/AppRegistry" @@ -8,14 +10,16 @@ import { ProfileTab } from "app/Navigation/AuthenticatedRoutes/ProfileTab" import { SearchTab } from "app/Navigation/AuthenticatedRoutes/SearchTab" import { SellTab } from "app/Navigation/AuthenticatedRoutes/SellTab" import { registerSharedModalRoutes } from "app/Navigation/AuthenticatedRoutes/SharedRoutes" +import { useBottomTabsBadges } from "app/Navigation/Utils/useBottomTabsBadges" import { BottomTabOption, BottomTabType } from "app/Scenes/BottomTabs/BottomTabType" -import { BottomTabsButton } from "app/Scenes/BottomTabs/BottomTabsButton" +import { BottomTabsIcon } from "app/Scenes/BottomTabs/BottomTabsIcon" import { bottomTabsConfig } from "app/Scenes/BottomTabs/bottomTabsConfig" import { OnboardingQuiz } from "app/Scenes/Onboarding/OnboardingQuiz/OnboardingQuiz" import { GlobalStore } from "app/store/GlobalStore" import { internal_navigationRef } from "app/system/navigation/navigate" import { postEventToProviders } from "app/utils/track/providers" import { Platform } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" if (Platform.OS === "ios") { require("app/Navigation/AuthenticatedRoutes/NativeScreens") @@ -39,7 +43,12 @@ type TabRoutesParams = { const Tab = createBottomTabNavigator() +const BOTTOM_TABS_HEIGHT = 60 + const AppTabs: React.FC = () => { + const { tabsBadges } = useBottomTabsBadges() + const insets = useSafeAreaInsets() + return ( { @@ -49,15 +58,26 @@ const AppTabs: React.FC = () => { tabBarStyle: { animate: true, position: "absolute", + height: BOTTOM_TABS_HEIGHT + insets.bottom, display: currentRoute && modules[currentRoute as AppModule]?.options.hidesBottomTabs ? "none" : "flex", }, tabBarHideOnKeyboard: true, - tabBarButton: (props) => { - return + tabBarIcon: ({ focused }) => { + return + }, + tabBarLabel: () => { + return ( + + {bottomTabsConfig[route.name].name} + + ) }, + tabBarActiveTintColor: THEME.colors["black100"], + tabBarInactiveTintColor: THEME.colors["black60"], + ...tabsBadges[route.name], } }} screenListeners={{ diff --git a/src/app/Navigation/Utils/useBottomTabsBadges.tests.ts b/src/app/Navigation/Utils/useBottomTabsBadges.tests.ts new file mode 100644 index 00000000000..8d819bf8b14 --- /dev/null +++ b/src/app/Navigation/Utils/useBottomTabsBadges.tests.ts @@ -0,0 +1,97 @@ +import { useColor, useSpace } from "@artsy/palette-mobile" +import { renderHook } from "@testing-library/react-hooks" +import { useVisualClue } from "app/utils/hooks/useVisualClue" +import { useTabBarBadge } from "app/utils/useTabBarBadge" +import { useBottomTabsBadges } from "./useBottomTabsBadges" + +// Mocking the necessary imports +jest.mock("@artsy/palette-mobile", () => ({ + useColor: jest.fn(), + useSpace: jest.fn(), +})) +jest.mock("app/utils/hooks/useVisualClue", () => ({ + useVisualClue: jest.fn(), +})) + +jest.mock("app/utils/useTabBarBadge", () => ({ + useTabBarBadge: jest.fn(), +})) + +// Settings for the test +describe("useBottomTabsBadges", () => { + const mockUseColor = useColor as jest.Mock + const mockUseSpace = useSpace as jest.Mock + const mockUseVisualClue = useVisualClue as jest.Mock + const mockUseTabBarBadge = useTabBarBadge as jest.Mock + + beforeEach(() => { + mockUseColor.mockReturnValue((color: string) => color) + mockUseSpace.mockReturnValue(() => 10) + }) + + it("returns default badge states when no clues or notifications are present", () => { + mockUseVisualClue.mockReturnValue({ showVisualClue: () => false }) + mockUseTabBarBadge.mockReturnValue({ + unreadConversationsCount: 0, + hasUnseenNotifications: false, + }) + + const { result } = renderHook(() => useBottomTabsBadges()) + + expect(result.current.tabsBadges).toMatchObject({ + home: { badgeCount: undefined, badgeStyle: {} }, + search: { badgeCount: undefined, badgeStyle: {} }, + inbox: { badgeCount: undefined, badgeStyle: {} }, + sell: { badgeCount: undefined, badgeStyle: {} }, + profile: { badgeCount: undefined, badgeStyle: {} }, + }) + }) + + it('updates badge for "home" tab when unseen notifications are present', () => { + mockUseVisualClue.mockReturnValue({ showVisualClue: () => false }) + mockUseTabBarBadge.mockReturnValue({ + hasUnseenNotifications: true, + }) + + const { result } = renderHook(() => useBottomTabsBadges()) + + expect(result.current.tabsBadges.home).toMatchObject({ + badgeCount: "", + badgeStyle: { + // Whatever style we have here + }, + }) + }) + + it('updates badge for "inbox" tab when unseen notifications are present', () => { + mockUseVisualClue.mockReturnValue({ showVisualClue: () => false }) + mockUseTabBarBadge.mockReturnValue({ + unreadConversationsCount: 5, + }) + + const { result } = renderHook(() => useBottomTabsBadges()) + + expect(result.current.tabsBadges.inbox).toMatchObject({ + badgeCount: 5, + badgeStyle: { + // Whatever style we have here + }, + }) + }) + + it('prioritises conversations count over visual clues for "inbox" tab', () => { + mockUseVisualClue.mockReturnValue({ showVisualClue: () => true }) + mockUseTabBarBadge.mockReturnValue({ + unreadConversationsCount: 5, + }) + + const { result } = renderHook(() => useBottomTabsBadges()) + + expect(result.current.tabsBadges.inbox).toMatchObject({ + badgeCount: 5, + badgeStyle: { + // Whatever style we have here + }, + }) + }) +}) diff --git a/src/app/Navigation/Utils/useBottomTabsBadges.ts b/src/app/Navigation/Utils/useBottomTabsBadges.ts new file mode 100644 index 00000000000..41683e1f4b3 --- /dev/null +++ b/src/app/Navigation/Utils/useBottomTabsBadges.ts @@ -0,0 +1,90 @@ +import { useColor, useSpace } from "@artsy/palette-mobile" +import { BottomTabType } from "app/Scenes/BottomTabs/BottomTabType" +import { bottomTabsConfig } from "app/Scenes/BottomTabs/bottomTabsConfig" +import { useVisualClue } from "app/utils/hooks/useVisualClue" +import { useTabBarBadge } from "app/utils/useTabBarBadge" +import { StyleProp, TextStyle } from "react-native" + +const VISUAL_CLUE_HEIGHT = 10 + +type BadgeProps = { badgeCount?: string | number; badgeStyle: StyleProp } +/** + * This hook is used to get badge details for each bottom tab + * @returns an object with the badge count and style for each tab + * @example { home: { badgeCount: 5, badgeStyle: { backgroundColor: "red" } }, search: { badgeCount: undefined, badgeStyle: {} } } + */ +export const useBottomTabsBadges = () => { + const color = useColor() + const space = useSpace() + + const { showVisualClue } = useVisualClue() + + const { unreadConversationsCount, hasUnseenNotifications } = useTabBarBadge() + + const tabsBadges: Record = {} + + const visualClueStyles = { + backgroundColor: color("blue100"), + top: space(1), + minWidth: VISUAL_CLUE_HEIGHT, + maxHeight: VISUAL_CLUE_HEIGHT, + borderRadius: VISUAL_CLUE_HEIGHT / 2, + borderColor: color("white100"), + borderWidth: 1, + } + + const hasVisualCluesForTab = (tab: BottomTabType) => { + const visualClues = bottomTabsConfig[tab].visualClues + if (!visualClues) { + return false + } + + return visualClues?.find(showVisualClue) + } + + Object.keys(bottomTabsConfig).forEach((tab) => { + const defaultBadgeProps: BadgeProps = { + badgeCount: undefined, + badgeStyle: {}, + } + + tabsBadges[tab] = defaultBadgeProps + + if (hasVisualCluesForTab(tab as BottomTabType)) { + tabsBadges[tab] = { + badgeCount: "", + badgeStyle: { + ...visualClueStyles, + }, + } + } + + switch (tab) { + case "home": { + if (hasUnseenNotifications) { + tabsBadges[tab] = { + badgeCount: "", + badgeStyle: { + ...visualClueStyles, + }, + } + } + return + } + + case "inbox": { + if (unreadConversationsCount) { + tabsBadges[tab] = { + badgeCount: unreadConversationsCount, + badgeStyle: { + backgroundColor: color("red100"), + }, + } + } + return + } + } + }) + + return { tabsBadges } +} diff --git a/src/app/Scenes/BottomTabs/bottomTabsConfig.tsx b/src/app/Scenes/BottomTabs/bottomTabsConfig.tsx index e4267175927..8510aef0aba 100644 --- a/src/app/Scenes/BottomTabs/bottomTabsConfig.tsx +++ b/src/app/Scenes/BottomTabs/bottomTabsConfig.tsx @@ -9,6 +9,7 @@ export const bottomTabsConfig: { route: BottomTabRoute analyticsDescription: TappedTabBarArgs["tab"] name: string + visualClues?: string[] } } = { home: {