diff --git a/assets/images/simple-illustrations/simple-illustration__empty-state.svg b/assets/images/simple-illustrations/simple-illustration__empty-state.svg new file mode 100644 index 000000000000..154b2269c285 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__empty-state.svg @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CONST.ts b/src/CONST.ts index b809bdaacaf6..f50914aab5fc 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5250,6 +5250,13 @@ const CONST = { }, EXCLUDE_FROM_LAST_VISITED_PATH: [SCREENS.NOT_FOUND, SCREENS.SAML_SIGN_IN, SCREENS.VALIDATE_LOGIN] as string[], + + EMPTY_STATE_MEDIA: { + ANIMATION: 'animation', + ILLUSTRATION: 'illustration', + VIDEO: 'video', + }, + UPGRADE_FEATURE_INTRO_MAPPING: [ { id: 'reportFields', @@ -5260,6 +5267,7 @@ const CONST = { icon: 'Pencil', }, ], + REPORT_FIELD_TYPES: { TEXT: 'text', DATE: 'date', diff --git a/src/components/AccountingListSkeletonView.tsx b/src/components/AccountingListSkeletonView.tsx index b977903d3adc..dbe8ada6c4b7 100644 --- a/src/components/AccountingListSkeletonView.tsx +++ b/src/components/AccountingListSkeletonView.tsx @@ -4,12 +4,14 @@ import ItemListSkeletonView from './Skeletons/ItemListSkeletonView'; type AccountingListSkeletonViewProps = { shouldAnimate?: boolean; + gradientOpacityEnabled?: boolean; }; -function AccountingListSkeletonView({shouldAnimate = true}: AccountingListSkeletonViewProps) { +function AccountingListSkeletonView({shouldAnimate = true, gradientOpacityEnabled = false}: AccountingListSkeletonViewProps) { return ( ( <> { + if (!event) { + return; + } + + if ('naturalSize' in event) { + setVideoAspectRatio(event.naturalSize.width / event.naturalSize.height); + } else { + setVideoAspectRatio(event.srcElement.videoWidth / event.srcElement.videoHeight); + } + }; + + const HeaderComponent = useMemo(() => { + switch (headerMediaType) { + case CONST.EMPTY_STATE_MEDIA.VIDEO: + return ( + + ); + case CONST.EMPTY_STATE_MEDIA.ANIMATION: + return ( + + ); + case CONST.EMPTY_STATE_MEDIA.ILLUSTRATION: + return ( + + ); + default: + return null; + } + }, [headerMedia, headerMediaType, headerContentStyles, videoAspectRatio, styles.emptyStateVideo]); + + return ( + + + + + + + {HeaderComponent} + + {title} + {subtitle} + {!!buttonText && !!buttonAction && ( + + )} + + + + + ); +} + +EmptyStateComponent.displayName = 'EmptyStateComponent'; +export default EmptyStateComponent; diff --git a/src/components/EmptyStateComponent/types.ts b/src/components/EmptyStateComponent/types.ts new file mode 100644 index 000000000000..326b25542f42 --- /dev/null +++ b/src/components/EmptyStateComponent/types.ts @@ -0,0 +1,41 @@ +import type {ImageStyle} from 'expo-image'; +import type {StyleProp, ViewStyle} from 'react-native'; +import type {ValueOf} from 'type-fest'; +import type DotLottieAnimation from '@components/LottieAnimations/types'; +import type SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; +import type TableRowSkeleton from '@components/Skeletons/TableRowSkeleton'; +import type CONST from '@src/CONST'; +import type IconAsset from '@src/types/utils/IconAsset'; + +type ValidSkeletons = typeof SearchRowSkeleton | typeof TableRowSkeleton; +type MediaTypes = ValueOf; + +type SharedProps = { + SkeletonComponent: ValidSkeletons; + title: string; + subtitle: string; + buttonText?: string; + buttonAction?: () => void; + headerStyles?: StyleProp; + headerMediaType: T; + headerContentStyles?: StyleProp; +}; + +type MediaType = SharedProps & { + headerMedia: HeaderMedia; +}; + +type VideoProps = MediaType; +type IllustrationProps = MediaType; +type AnimationProps = MediaType; + +type EmptyStateComponentProps = VideoProps | IllustrationProps | AnimationProps; + +type VideoLoadedEventType = { + srcElement: { + videoWidth: number; + videoHeight: number; + }; +}; + +export type {EmptyStateComponentProps, VideoLoadedEventType}; diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts index 7a8186d2f38e..da72b3025340 100644 --- a/src/components/Icon/Illustrations.ts +++ b/src/components/Icon/Illustrations.ts @@ -53,6 +53,7 @@ import ConciergeNew from '@assets/images/simple-illustrations/simple-illustratio import CreditCardsNew from '@assets/images/simple-illustrations/simple-illustration__credit-cards.svg'; import CreditCardEyes from '@assets/images/simple-illustrations/simple-illustration__creditcardeyes.svg'; import EmailAddress from '@assets/images/simple-illustrations/simple-illustration__email-address.svg'; +import EmptyState from '@assets/images/simple-illustrations/simple-illustration__empty-state.svg'; import FolderOpen from '@assets/images/simple-illustrations/simple-illustration__folder-open.svg'; import Gears from '@assets/images/simple-illustrations/simple-illustration__gears.svg'; import HandCard from '@assets/images/simple-illustrations/simple-illustration__handcard.svg'; @@ -198,6 +199,7 @@ export { CheckmarkCircle, CreditCardEyes, LockClosedOrange, + EmptyState, FolderWithPapers, VirtualCard, }; diff --git a/src/components/OptionsListSkeletonView.tsx b/src/components/OptionsListSkeletonView.tsx index a11077f95bb5..6dede512f405 100644 --- a/src/components/OptionsListSkeletonView.tsx +++ b/src/components/OptionsListSkeletonView.tsx @@ -18,16 +18,18 @@ function getLinedWidth(index: number): string { type OptionsListSkeletonViewProps = { shouldAnimate?: boolean; + gradientOpacityEnabled?: boolean; shouldStyleAsTable?: boolean; }; -function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false}: OptionsListSkeletonViewProps) { +function OptionsListSkeletonView({shouldAnimate = true, shouldStyleAsTable = false, gradientOpacityEnabled = false}: OptionsListSkeletonViewProps) { const styles = useThemeStyles(); return ( { const lineWidth = getLinedWidth(itemIndex); diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx index fc5c23d5c9ec..6ea977d1bf36 100644 --- a/src/components/Search/index.tsx +++ b/src/components/Search/index.tsx @@ -5,7 +5,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import {useOnyx} from 'react-native-onyx'; import SearchTableHeader from '@components/SelectionList/SearchTableHeader'; import type {ReportListItemType, TransactionListItemType} from '@components/SelectionList/types'; -import TableListItemSkeleton from '@components/Skeletons/TableListItemSkeleton'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -101,7 +101,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { query={query} hash={hash} /> - + ); } @@ -207,7 +207,7 @@ function Search({query, policyIDs, sortBy, sortOrder}: SearchProps) { onEndReached={fetchMoreResults} listFooterContent={ isLoadingMoreItems ? ( - diff --git a/src/components/Skeletons/ItemListSkeletonView.tsx b/src/components/Skeletons/ItemListSkeletonView.tsx index 1ee2da8a8019..046cdfffbee5 100644 --- a/src/components/Skeletons/ItemListSkeletonView.tsx +++ b/src/components/Skeletons/ItemListSkeletonView.tsx @@ -1,6 +1,6 @@ -import React, {useMemo, useState} from 'react'; -import {View} from 'react-native'; -import type {StyleProp, ViewStyle} from 'react-native'; +import React, {useCallback, useMemo, useState} from 'react'; +import type {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import SkeletonViewContentLoader from '@components/SkeletonViewContentLoader'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -10,22 +10,62 @@ type ListItemSkeletonProps = { shouldAnimate?: boolean; renderSkeletonItem: (args: {itemIndex: number}) => React.ReactNode; fixedNumItems?: number; + gradientOpacityEnabled?: boolean; itemViewStyle?: StyleProp; itemViewHeight?: number; }; -function ItemListSkeletonView({shouldAnimate = true, renderSkeletonItem, fixedNumItems, itemViewStyle = {}, itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT}: ListItemSkeletonProps) { +const getVerticalMargin = (style: StyleProp): number => { + if (!style) { + return 0; + } + + const flattenStyle = StyleSheet.flatten(style); + const marginVertical = Number(flattenStyle?.marginVertical ?? 0); + const marginTop = Number(flattenStyle?.marginTop ?? 0); + const marginBottom = Number(flattenStyle?.marginBottom ?? 0); + + return marginVertical + marginTop + marginBottom; +}; + +function ItemListSkeletonView({ + shouldAnimate = true, + renderSkeletonItem, + fixedNumItems, + gradientOpacityEnabled = false, + itemViewStyle = {}, + itemViewHeight = CONST.LHN_SKELETON_VIEW_ITEM_HEIGHT, +}: ListItemSkeletonProps) { const theme = useTheme(); const themeStyles = useThemeStyles(); const [numItems, setNumItems] = useState(fixedNumItems ?? 0); + + const totalItemHeight = itemViewHeight + getVerticalMargin(itemViewStyle); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + if (fixedNumItems) { + return; + } + + const totalHeight = event.nativeEvent.layout.height; + const newNumItems = Math.ceil(totalHeight / totalItemHeight); + if (newNumItems !== numItems) { + setNumItems(newNumItems); + } + }, + [fixedNumItems, numItems, totalItemHeight], + ); + const skeletonViewItems = useMemo(() => { const items = []; for (let i = 0; i < numItems; i++) { + const opacity = gradientOpacityEnabled ? 1 - i / (numItems - 1) : 1; items.push( { - if (fixedNumItems) { - return; - } - - const newNumItems = Math.ceil(event.nativeEvent.layout.height / itemViewHeight); - if (newNumItems === numItems) { - return; - } - setNumItems(newNumItems); - }} + onLayout={handleLayout} > - {skeletonViewItems} + {skeletonViewItems} ); } diff --git a/src/components/Skeletons/TableListItemSkeleton.tsx b/src/components/Skeletons/SearchRowSkeleton.tsx similarity index 54% rename from src/components/Skeletons/TableListItemSkeleton.tsx rename to src/components/Skeletons/SearchRowSkeleton.tsx index 6ff3a3aedbb9..2359e47b7520 100644 --- a/src/components/Skeletons/TableListItemSkeleton.tsx +++ b/src/components/Skeletons/SearchRowSkeleton.tsx @@ -2,26 +2,41 @@ import React from 'react'; import {Circle, Rect} from 'react-native-svg'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; +import variables from '@styles/variables'; import CONST from '@src/CONST'; import ItemListSkeletonView from './ItemListSkeletonView'; -type TableListItemSkeletonProps = { +type SearchRowSkeletonProps = { shouldAnimate?: boolean; fixedNumItems?: number; + gradientOpacityEnabled?: boolean; }; -const barHeight = '10'; -const shortBarWidth = '40'; -const longBarWidth = '120'; +const barHeight = 8; +const longBarWidth = 120; +const leftPaneWidth = variables.sideBarWidth; -function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListItemSkeletonProps) { +// 12 is the gap between the element and the right button +const gapWidth = 12; + +// 80 is the width of the element itself +const rightSideElementWidth = 80; + +// 24 is the padding of the central pane summing two sides +const centralPanePadding = 40; + +// 80 is the width of the button on the right side +const rightButtonWidth = 80; + +function SearchRowSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: SearchRowSkeletonProps) { const styles = useThemeStyles(); - const {windowWidth, isSmallScreenWidth} = useWindowDimensions(); + const {windowWidth, isSmallScreenWidth, isLargeScreenWidth} = useWindowDimensions(); if (isSmallScreenWidth) { return ( ( @@ -51,7 +66,7 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI height={4} /> ); } + return ( ( <> - + {isLargeScreenWidth && ( + <> + + + + + )} + + )} @@ -146,6 +181,6 @@ function TableListItemSkeleton({shouldAnimate = true, fixedNumItems}: TableListI ); } -TableListItemSkeleton.displayName = 'TableListItemSkeleton'; +SearchRowSkeleton.displayName = 'SearchRowSkeleton'; -export default TableListItemSkeleton; +export default SearchRowSkeleton; diff --git a/src/components/Skeletons/TableRowSkeleton.tsx b/src/components/Skeletons/TableRowSkeleton.tsx new file mode 100644 index 000000000000..865bffc5842f --- /dev/null +++ b/src/components/Skeletons/TableRowSkeleton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import {Circle, Rect} from 'react-native-svg'; +import useThemeStyles from '@hooks/useThemeStyles'; +import ItemListSkeletonView from './ItemListSkeletonView'; + +type TableListItemSkeletonProps = { + shouldAnimate?: boolean; + fixedNumItems?: number; + gradientOpacityEnabled?: boolean; +}; + +const barHeight = '8'; +const shortBarWidth = '60'; +const longBarWidth = '124'; + +function TableListItemSkeleton({shouldAnimate = true, fixedNumItems, gradientOpacityEnabled = false}: TableListItemSkeletonProps) { + const styles = useThemeStyles(); + + return ( + ( + <> + + + + + )} + /> + ); +} + +TableListItemSkeleton.displayName = 'TableListItemSkeleton'; + +export default TableListItemSkeleton; diff --git a/src/pages/Search/EmptySearchView.tsx b/src/pages/Search/EmptySearchView.tsx index bfeb46f06298..474cbfc0aff8 100644 --- a/src/pages/Search/EmptySearchView.tsx +++ b/src/pages/Search/EmptySearchView.tsx @@ -1,14 +1,22 @@ import React from 'react'; +import EmptyStateComponent from '@components/EmptyStateComponent'; import * as Illustrations from '@components/Icon/Illustrations'; -import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; +import SearchRowSkeleton from '@components/Skeletons/SearchRowSkeleton'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import CONST from '@src/CONST'; function EmptySearchView() { const {translate} = useLocalize(); + const styles = useThemeStyles(); return ( - diff --git a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx index 633d1833e43f..174251a80d5f 100644 --- a/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx +++ b/src/pages/workspace/categories/WorkspaceCategoriesPage.tsx @@ -7,6 +7,7 @@ import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import ConfirmModal from '@components/ConfirmModal'; +import EmptyStateComponent from '@components/EmptyStateComponent'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; @@ -15,9 +16,9 @@ import SelectionList from '@components/SelectionList'; import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; import type {ListItem} from '@components/SelectionList/types'; +import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -317,10 +318,15 @@ function WorkspaceCategoriesPage({route}: WorkspaceCategoriesPageProps) { color={theme.spinner} /> )} + {!hasVisibleCategories && !isLoading && ( - )} diff --git a/src/pages/workspace/tags/WorkspaceTagsPage.tsx b/src/pages/workspace/tags/WorkspaceTagsPage.tsx index 92b016766742..cf9952720fc9 100644 --- a/src/pages/workspace/tags/WorkspaceTagsPage.tsx +++ b/src/pages/workspace/tags/WorkspaceTagsPage.tsx @@ -7,6 +7,7 @@ import Button from '@components/Button'; import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu'; import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types'; import ConfirmModal from '@components/ConfirmModal'; +import EmptyStateComponent from '@components/EmptyStateComponent'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; @@ -14,9 +15,9 @@ import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import ListItemRightCaretWithLabel from '@components/SelectionList/ListItemRightCaretWithLabel'; import TableListItem from '@components/SelectionList/TableListItem'; +import TableListItemSkeleton from '@components/Skeletons/TableRowSkeleton'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import WorkspaceEmptyStateSection from '@components/WorkspaceEmptyStateSection'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; @@ -343,9 +344,13 @@ function WorkspaceTagsPage({route}: WorkspaceTagsPageProps) { /> )} {!hasVisibleTag && !isLoading && ( - )} diff --git a/src/styles/index.ts b/src/styles/index.ts index a0795e5d378a..e422b8bd3d1e 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5083,6 +5083,59 @@ const styles = (theme: ThemeColors) => fontSize: variables.fontSizeNormal, fontWeight: FontUtils.fontWeight.bold, }, + + skeletonBackground: { + flex: 1, + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + paddingRight: 8, + paddingLeft: 8, + }, + + emptyStateScrollView: { + minHeight: 400, + height: '100%', + flex: 1, + }, + + emptyStateForeground: (isSmallScreenWidth: boolean) => ({ + justifyContent: 'center', + alignItems: 'center', + height: '100%', + padding: isSmallScreenWidth ? 32 : 0, + width: '100%', + }), + + emptyStateContent: { + backgroundColor: theme.cardBG, + borderRadius: variables.componentBorderRadiusLarge, + maxWidth: 400, + }, + + emptyStateHeader: (isIllustration: boolean) => ({ + borderTopLeftRadius: variables.componentBorderRadiusLarge, + borderTopRightRadius: variables.componentBorderRadiusLarge, + minHeight: 200, + alignItems: isIllustration ? 'center' : undefined, + justifyContent: isIllustration ? 'center' : undefined, + }), + + emptyFolderBG: { + backgroundColor: theme.emptyFolderBG, + }, + + emptyStateVideo: { + borderTopLeftRadius: variables.componentBorderRadiusLarge, + borderTopRightRadius: variables.componentBorderRadiusLarge, + }, + + emptyStateFolderIconSize: { + width: 184, + height: 112, + }, } satisfies Styles); type ThemeStyles = ReturnType; diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index b316f116c805..7ed23c7c0991 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -93,6 +93,7 @@ const darkTheme = { white: colors.white, videoPlayerBG: `${colors.productDark100}cc`, transparentWhite: `${colors.white}51`, + emptyFolderBG: colors.yellow600, // Adding a color here will animate the status bar to the right color when the screen is opened. // Note that it needs to be a screen name, not a route url. diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 05364515e264..2ebb558ee20b 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -93,6 +93,7 @@ const lightTheme = { white: colors.white, videoPlayerBG: `${colors.productDark100}cc`, transparentWhite: `${colors.white}51`, + emptyFolderBG: colors.yellow600, // Adding a color here will animate the status bar to the right color when the screen is opened. // Note that it needs to be a screen name, not a route url. diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index 2d8618c24ebb..ffa42e99777d 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -97,6 +97,7 @@ type ThemeColors = { white: Color; videoPlayerBG: Color; transparentWhite: Color; + emptyFolderBG: Color; PAGE_THEMES: Record;