Skip to content

Commit

Permalink
Merge pull request #40675 from tienifr/feature/track-expense-training…
Browse files Browse the repository at this point in the history
…-modal

Feature: Track expense training modal
  • Loading branch information
srikarparsi authored Apr 25, 2024
2 parents 6060e6f + bbb3289 commit 1276b1d
Show file tree
Hide file tree
Showing 18 changed files with 393 additions and 155 deletions.
10 changes: 10 additions & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3440,6 +3440,16 @@ const CONST = {
LINK: 'https://join.my.expensify.com',
},

FEATURE_TRAINING: {
CONTENT_TYPES: {
TRACK_EXPENSE: 'track-expenses',
},
'track-expenses': {
VIDEO_URL: `${CLOUDFRONT_URL}/videos/guided-setup-track-business.mp4`,
LEARN_MORE_LINK: `${USE_EXPENSIFY_URL}/track-expenses`,
},
},

/**
* native IDs for close buttons in Overlay component
*/
Expand Down
1 change: 1 addition & 0 deletions src/NAVIGATORS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default {
LEFT_MODAL_NAVIGATOR: 'LeftModalNavigator',
RIGHT_MODAL_NAVIGATOR: 'RightModalNavigator',
ONBOARDING_MODAL_NAVIGATOR: 'OnboardingModalNavigator',
FEATURE_TRANING_MODAL_NAVIGATOR: 'FeatureTrainingModalNavigator',
WELCOME_VIDEO_MODAL_NAVIGATOR: 'WelcomeVideoModalNavigator',
FULL_SCREEN_NAVIGATOR: 'FullScreenNavigator',
} as const;
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,9 @@ const ONYXKEYS = {
/** This NVP contains the referral banners the user dismissed */
NVP_DISMISSED_REFERRAL_BANNERS: 'nvp_dismissedReferralBanners',

/** This NVP contains the training modals the user denied showing again */
NVP_HAS_SEEN_TRACK_TRAINING: 'nvp_hasSeenTrackTraining',

/** Indicates which locale should be used */
NVP_PREFERRED_LOCALE: 'nvp_preferredLocale',

Expand Down Expand Up @@ -615,6 +618,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale;
[ONYXKEYS.NVP_ACTIVE_POLICY_ID]: string;
[ONYXKEYS.NVP_DISMISSED_REFERRAL_BANNERS]: OnyxTypes.DismissedReferralBanners;
[ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING]: boolean;
[ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet;
[ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido;
[ONYXKEYS.WALLET_ADDITIONAL_DETAILS]: OnyxTypes.WalletAdditionalDetails;
Expand Down
1 change: 1 addition & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,7 @@ const ROUTES = {
route: 'referral/:contentType',
getRoute: (contentType: string, backTo?: string) => getUrlWithBackToParam(`referral/${contentType}`, backTo),
},
TRACK_TRAINING_MODAL: 'track-training',
PROCESS_MONEY_REQUEST_HOLD: 'hold-expense-educational',
ONBOARDING_ROOT: 'onboarding',
ONBOARDING_PERSONAL_DETAILS: 'onboarding/personal-details',
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ const SCREENS = {
REFERRAL_DETAILS: 'Referral_Details',
KEYBOARD_SHORTCUTS: 'KeyboardShortcuts',
TRANSACTION_RECEIPT: 'TransactionReceipt',
FEATURE_TRAINING_ROOT: 'FeatureTraining_Root',
} as const;

type Screen = DeepValueOf<typeof SCREENS>;
Expand Down
237 changes: 237 additions & 0 deletions src/components/FeatureTrainingModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import type {VideoReadyForDisplayEvent} from 'expo-av';
import React, {useCallback, useEffect, useState} from 'react';
import {View} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnboardingLayout from '@hooks/useOnboardingLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Navigation from '@libs/Navigation/Navigation';
import variables from '@styles/variables';
import * as User from '@userActions/User';
import CONST from '@src/CONST';
import Button from './Button';
import CheckboxWithLabel from './CheckboxWithLabel';
import Lottie from './Lottie';
import LottieAnimations from './LottieAnimations';
import type DotLottieAnimation from './LottieAnimations/types';
import Modal from './Modal';
import SafeAreaConsumer from './SafeAreaConsumer';
import Text from './Text';
import VideoPlayer from './VideoPlayer';

// Aspect ratio and height of the video.
// Useful before video loads to reserve space.
const VIDEO_ASPECT_RATIO = 1280 / 960;

const MODAL_PADDING = variables.spacing2;

type VideoLoadedEventType = {
srcElement: {
videoWidth: number;
videoHeight: number;
};
};

type VideoStatus = 'video' | 'animation';

type FeatureTrainingModalProps = {
/** Animation to show when video is unavailable. Useful when app is offline */
animation?: DotLottieAnimation;

/** URL for the video */
videoURL: string;

videoAspectRatio?: number;

/** Title for the modal */
title?: string;

/** Describe what is showing */
description?: string;

/** Whether to show `Don't show me this again` option */
shouldShowDismissModalOption?: boolean;

/** Text to show on primary button */
confirmText: string;

/** A callback to call when user confirms the tutorial */
onConfirm?: () => void;

/** Text to show on secondary button */
helpText?: string;

/** Link to navigate to when user wants to learn more */
onHelp?: () => void;
};

function FeatureTrainingModal({
animation,
videoURL,
videoAspectRatio: videoAspectRatioProp,
title = '',
description = '',
shouldShowDismissModalOption = false,
confirmText = '',
onConfirm = () => {},
helpText = '',
onHelp = () => {},
}: FeatureTrainingModalProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {shouldUseNarrowLayout} = useOnboardingLayout();
const [isModalVisible, setIsModalVisible] = useState(true);
const [willShowAgain, setWillShowAgain] = useState(true);
const [videoStatus, setVideoStatus] = useState<VideoStatus>('video');
const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false);
const [videoAspectRatio, setVideoAspectRatio] = useState(videoAspectRatioProp ?? VIDEO_ASPECT_RATIO);
const {isSmallScreenWidth} = useWindowDimensions();
const {isOffline} = useNetwork();

useEffect(() => {
if (isVideoStatusLocked) {
return;
}

if (isOffline) {
setVideoStatus('animation');
} else if (!isOffline) {
setVideoStatus('video');
setIsVideoStatusLocked(true);
}
}, [isOffline, isVideoStatusLocked]);

const setAspectRatio = (event: VideoReadyForDisplayEvent | VideoLoadedEventType | undefined) => {
if (!event) {
return;
}

if ('naturalSize' in event) {
setVideoAspectRatio(event.naturalSize.width / event.naturalSize.height);
} else {
setVideoAspectRatio(event.srcElement.videoWidth / event.srcElement.videoHeight);
}
};

const renderIllustration = useCallback(() => {
const aspectRatio = videoAspectRatio || VIDEO_ASPECT_RATIO;

return (
<View
style={[
styles.w100,
// Prevent layout jumps by reserving height
// for the video until it loads. Also, when
// videoStatus === 'animation' it will
// set the same aspect ratio as the video would.
{aspectRatio},
]}
>
{videoStatus === 'video' ? (
<VideoPlayer
url={videoURL}
videoPlayerStyle={[styles.onboardingVideoPlayer, {aspectRatio}]}
onVideoLoaded={setAspectRatio}
controlsStatus={CONST.VIDEO_PLAYER.CONTROLS_STATUS.SHOW}
shouldUseControlsBottomMargin={false}
shouldPlay
isLooping
/>
) : (
<View style={[styles.flex1, styles.alignItemsCenter, {aspectRatio}]}>
<Lottie
source={animation ?? LottieAnimations.Hands}
style={styles.h100}
webStyle={isSmallScreenWidth ? styles.h100 : undefined}
autoPlay
loop
/>
</View>
)}
</View>
);
}, [animation, videoURL, videoAspectRatio, videoStatus, isSmallScreenWidth, styles]);

const toggleWillShowAgain = useCallback(() => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain), []);

const closeModal = useCallback(() => {
if (!willShowAgain) {
User.dismissTrackTrainingModal();
}
setIsModalVisible(false);
Navigation.goBack();
}, [willShowAgain]);

const closeAndConfirmModal = useCallback(() => {
closeModal();
onConfirm?.();
}, [onConfirm, closeModal]);

return (
<SafeAreaConsumer>
{({safeAreaPaddingBottomStyle}) => (
<Modal
isVisible={isModalVisible}
type={shouldUseNarrowLayout ? CONST.MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE : CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED}
onClose={closeModal}
innerContainerStyle={{
boxShadow: 'none',
borderRadius: 16,
paddingBottom: 20,
paddingTop: shouldUseNarrowLayout ? undefined : MODAL_PADDING,
...(shouldUseNarrowLayout
? // Override styles defined by MODAL.MODAL_TYPE.CENTERED_UNSWIPEABLE
// To make it take as little space as possible.
{
flex: undefined,
width: 'auto',
}
: {}),
}}
>
<GestureHandlerRootView>
<View style={[styles.mh100, shouldUseNarrowLayout && styles.welcomeVideoNarrowLayout, safeAreaPaddingBottomStyle]}>
<View style={shouldUseNarrowLayout ? {padding: MODAL_PADDING} : {paddingHorizontal: MODAL_PADDING}}>{renderIllustration()}</View>
<View style={[styles.mt5, styles.mh5]}>
{title && description && (
<View style={[shouldUseNarrowLayout ? [styles.gap1, styles.mb8] : [styles.mb10]]}>
<Text style={[styles.textHeadlineH1]}>{title}</Text>
<Text style={styles.textSupporting}>{description}</Text>
</View>
)}
{shouldShowDismissModalOption && (
<CheckboxWithLabel
label={translate('featureTraining.doNotShowAgain')}
accessibilityLabel={translate('featureTraining.doNotShowAgain')}
style={[styles.mb5]}
isChecked={!willShowAgain}
onInputChange={toggleWillShowAgain}
/>
)}
{helpText && (
<Button
large
style={[styles.mb3]}
onPress={onHelp}
text={helpText}
/>
)}
<Button
large
success
pressOnEnter
onPress={closeAndConfirmModal}
text={confirmText}
/>
</View>
</View>
</GestureHandlerRootView>
</Modal>
)}
</SafeAreaConsumer>
);
}

export default FeatureTrainingModal;
Loading

0 comments on commit 1276b1d

Please sign in to comment.