diff --git a/src/components/CenteredModalLayout.tsx b/src/components/CenteredModalLayout.tsx
new file mode 100644
index 000000000000..a4ba0273b9aa
--- /dev/null
+++ b/src/components/CenteredModalLayout.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import type {MouseEvent} from 'react';
+import type {DimensionValue} from 'react-native';
+import {View} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+import FocusTrapForScreen from './FocusTrap/FocusTrapForScreen';
+import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
+
+type CenteredModalLayoutProps = {
+ children: React.ReactNode;
+
+ /** Width of the inner card on wide layouts (defaults to featureTrainingModalWidth) */
+ width?: number;
+
+ /** Width of the inner card on wide layout */
+ height?: DimensionValue;
+
+ /** Called when the backdrop is pressed, before navigating back */
+ onBackdropPress?: () => void;
+};
+
+function CenteredModalLayout({children, width, height, onBackdropPress}: CenteredModalLayoutProps) {
+ const styles = useThemeStyles();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {translate} = useLocalize();
+
+ const handleInnerClick = (e: MouseEvent) => e.stopPropagation();
+
+ return (
+
+
+ true}
+ onClick={handleInnerClick}
+ style={styles.getCenteredModalInnerView(shouldUseNarrowLayout, width, height)}
+ >
+ {children}
+
+
+
+ );
+}
+
+export default CenteredModalLayout;
diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx
deleted file mode 100644
index 5768d184fffa..000000000000
--- a/src/components/ExplanationModal.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import React from 'react';
-import useLocalize from '@hooks/useLocalize';
-import * as Welcome from '@userActions/Welcome';
-import CONST from '@src/CONST';
-import FeatureTrainingModal from './FeatureTrainingModal';
-
-function ExplanationModal() {
- const {translate} = useLocalize();
-
- return (
-
- );
-}
-
-export default ExplanationModal;
diff --git a/src/components/ExplanationModalScreen.tsx b/src/components/ExplanationModalScreen.tsx
new file mode 100644
index 000000000000..4ef4f649f160
--- /dev/null
+++ b/src/components/ExplanationModalScreen.tsx
@@ -0,0 +1,61 @@
+import React, {useRef} from 'react';
+import {View} from 'react-native';
+import useBeforeRemove from '@hooks/useBeforeRemove';
+import useBottomSafeSafeAreaPaddingStyle from '@hooks/useBottomSafeSafeAreaPaddingStyle';
+import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
+import useLocalize from '@hooks/useLocalize';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import Navigation from '@libs/Navigation/Navigation';
+import {completeHybridAppOnboarding} from '@userActions/Welcome';
+import CONST from '@src/CONST';
+import CenteredModalLayout from './CenteredModalLayout';
+import FeatureTrainingContent from './FeatureTrainingContent';
+
+function ExplanationModalScreen() {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const {windowWidth, windowHeight} = useWindowDimensions();
+
+ // In landscape mode the content is rendered in a ScrollView which handles the bottom safe area padding itself
+ const isContentScrollable = isInLandscapeModeUtil(windowWidth, windowHeight);
+
+ const contentStyle = useBottomSafeSafeAreaPaddingStyle({
+ addBottomSafeAreaPadding: !isContentScrollable,
+ style: [shouldUseNarrowLayout && styles.pt2, !isContentScrollable && styles.pb5],
+ });
+
+ // Mark hybrid-app onboarding complete however this screen is dismissed.
+ const hasCompletedOnboarding = useRef(false);
+ useBeforeRemove(() => {
+ if (hasCompletedOnboarding.current) {
+ return;
+ }
+ hasCompletedOnboarding.current = true;
+ completeHybridAppOnboarding();
+ });
+
+ const handleClose = () => Navigation.goBack();
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ESCAPE, handleClose, {shouldBubble: false});
+
+ return (
+
+
+
+
+
+ );
+}
+
+export default ExplanationModalScreen;
diff --git a/src/components/FeatureTrainingContent.tsx b/src/components/FeatureTrainingContent.tsx
new file mode 100644
index 000000000000..111e9ba1ea8e
--- /dev/null
+++ b/src/components/FeatureTrainingContent.tsx
@@ -0,0 +1,390 @@
+import type {ImageContentFit} from 'expo-image';
+import type {SourceLoadEventPayload} from 'expo-video';
+import React, {useEffect, useRef, useState} from 'react';
+import {Image, View} from 'react-native';
+// eslint-disable-next-line no-restricted-imports -- type-only import from react-native
+import type {ImageResizeMode, ImageSourcePropType, LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
+import {GestureHandlerRootView} from 'react-native-gesture-handler';
+import type {MergeExclusive} from 'type-fest';
+import useKeyboardState from '@hooks/useKeyboardState';
+import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useNetwork from '@hooks/useNetwork';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useThemeStyles from '@hooks/useThemeStyles';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import Accessibility from '@libs/Accessibility';
+import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
+import {getIsOffline} from '@libs/NetworkState';
+import variables from '@styles/variables';
+import CONST from '@src/CONST';
+import type IconAsset from '@src/types/utils/IconAsset';
+import Button from './Button';
+import CheckboxWithLabel from './CheckboxWithLabel';
+import FormAlertWithSubmitButton from './FormAlertWithSubmitButton';
+import ImageSVG from './ImageSVG';
+import type ImageSVGProps from './ImageSVG/types';
+import Lottie from './Lottie';
+import LottieAnimations from './LottieAnimations';
+import type DotLottieAnimation from './LottieAnimations/types';
+import OfflineIndicator from './OfflineIndicator';
+import RenderHTML from './RenderHTML';
+import ScrollView from './ScrollView';
+import Text from './Text';
+import VideoPlayer from './VideoPlayer';
+
+const VIDEO_ASPECT_RATIO = 1280 / 960;
+
+const CONTENT_PADDING = variables.spacing2;
+
+type VideoStatus = 'video' | 'animation';
+
+type BaseFeatureTrainingContentProps = {
+ /** The aspect ratio to preserve for the icon, video or animation */
+ illustrationAspectRatio?: number;
+
+ /** Style for the inner container of the animation */
+ illustrationInnerContainerStyle?: StyleProp;
+
+ /** Style for the outer container of the animation */
+ illustrationOuterContainerStyle?: StyleProp;
+
+ /** Title for the modal */
+ title?: string | React.ReactNode;
+
+ /** Describe what is showing */
+ description?: string;
+
+ /** Secondary description rendered with additional space */
+ secondaryDescription?: string;
+
+ /** Style for the title */
+ titleStyles?: StyleProp;
+
+ /** 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 */
+ onConfirm?: (willShowAgain: boolean) => void;
+
+ /** A callback to call when content wants to close */
+ onClose?: () => void;
+
+ /** Called whenever the "don't show again" checkbox value changes */
+ onWillShowAgainChange?: (willShowAgain: boolean) => void;
+
+ /** Text to show on secondary button */
+ helpText?: string;
+
+ /** Link to navigate to when user wants to learn more */
+ onHelp?: () => void;
+
+ /** Styles for the content container */
+ contentInnerContainerStyles?: StyleProp;
+
+ /** Styles for the content outer container */
+ contentOuterContainerStyles?: StyleProp;
+
+ /** Children to show below title and description and above buttons */
+ children?: React.ReactNode;
+
+ /** Content width for wide layouts */
+ width?: number;
+
+ /** Whether the image is a SVG */
+ shouldRenderSVG?: boolean;
+
+ /** Whether the description is written in HTML */
+ shouldRenderHTMLDescription?: boolean;
+
+ /** Whether closing should happen on confirm */
+ shouldCloseOnConfirm?: boolean;
+
+ /** Whether the content is scrollable */
+ shouldUseScrollView?: boolean;
+
+ /** Whether to show a confirmation loading spinner */
+ shouldShowConfirmationLoader?: boolean;
+
+ /** Whether the user can confirm while offline */
+ canConfirmWhileOffline?: boolean;
+
+ /** Sentry label for the help/skip button */
+ helpSentryLabel?: string;
+
+ /** Sentry label for the confirm/submit button */
+ confirmSentryLabel?: string;
+};
+
+type FeatureTrainingContentVideoProps = {
+ /** Animation to show when video is unavailable */
+ animation?: DotLottieAnimation;
+
+ /** Additional styles for the animation */
+ animationStyle?: StyleProp;
+
+ /** URL for the video */
+ videoURL?: string;
+};
+
+type FeatureTrainingContentSVGProps = {
+ /** Expensicon for the page */
+ image: IconAsset;
+
+ /** Determines how the image should be resized to fit its container */
+ contentFitImage?: ImageContentFit;
+
+ /** The width of the image */
+ imageWidth?: ImageSVGProps['width'];
+
+ /** The height of the image */
+ imageHeight?: ImageSVGProps['height'];
+};
+
+type FeatureTrainingContentProps = BaseFeatureTrainingContentProps & MergeExclusive;
+
+const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
+
+/**
+ * Once the device has been online, lock to 'video' permanently.
+ * While it has never been online, show 'animation' as a fallback.
+ */
+function useVideoStatus(): VideoStatus {
+ const [isLockedToVideo, setIsLockedToVideo] = useState(() => !getIsOffline());
+ const {isOffline} = useNetwork({
+ onReconnect: () => setIsLockedToVideo(true),
+ });
+
+ return isLockedToVideo || !isOffline ? 'video' : 'animation';
+}
+
+function FeatureTrainingContent({
+ animation,
+ animationStyle,
+ illustrationInnerContainerStyle,
+ illustrationOuterContainerStyle,
+ videoURL,
+ illustrationAspectRatio: illustrationAspectRatioProp,
+ image,
+ contentFitImage,
+ width = variables.featureTrainingModalWidth,
+ title = '',
+ description = '',
+ secondaryDescription = '',
+ titleStyles,
+ shouldShowDismissModalOption = false,
+ confirmText = '',
+ onConfirm,
+ onClose,
+ onWillShowAgainChange,
+ helpText = '',
+ onHelp,
+ children,
+ contentInnerContainerStyles,
+ contentOuterContainerStyles,
+ imageWidth,
+ imageHeight,
+ shouldRenderSVG = true,
+ shouldRenderHTMLDescription = false,
+ shouldCloseOnConfirm = true,
+ shouldUseScrollView: shouldUseScrollViewProp = false,
+ shouldShowConfirmationLoader = false,
+ canConfirmWhileOffline = true,
+ helpSentryLabel,
+ confirmSentryLabel,
+}: FeatureTrainingContentProps) {
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const {translate} = useLocalize();
+ const isReduceMotionEnabled = Accessibility.useReducedMotion();
+ const illustrations = useMemoizedLazyIllustrations(['Hands']);
+ const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
+ const {windowHeight, windowWidth} = useWindowDimensions();
+ const [willShowAgain, setWillShowAgain] = useState(true);
+ const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+ const videoStatus = useVideoStatus();
+ const scrollViewRef = useRef(null);
+ const [containerHeight, setContainerHeight] = useState(0);
+ const [contentHeight, setContentHeight] = useState(0);
+ const insets = useSafeAreaInsets();
+ const {isKeyboardActive} = useKeyboardState();
+ const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+
+ const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode;
+
+ const setAspectRatio = (event: SourceLoadEventPayload) => {
+ const track = event.availableVideoTracks.at(0);
+
+ if (!track) {
+ return;
+ }
+
+ setIllustrationAspectRatio(track.size.width / track.size.height);
+ };
+
+ const renderIllustration = () => {
+ const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO;
+
+ return (
+
+ {!!image &&
+ (shouldRenderSVG ? (
+
+ ) : (
+
+ ))}
+ {!!videoURL && videoStatus === 'video' && (
+
+
+
+ )}
+ {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
+
+ {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+ };
+
+ const toggleWillShowAgain = () => {
+ onWillShowAgainChange?.(!willShowAgain);
+ setWillShowAgain((prev) => {
+ const next = !prev;
+ return next;
+ });
+ };
+
+ const handleConfirm = () => {
+ onConfirm?.(willShowAgain);
+ if (shouldCloseOnConfirm) {
+ onClose?.();
+ }
+ };
+
+ useEffect(() => {
+ if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
+ return;
+ }
+ scrollViewRef.current?.scrollToEnd({animated: false});
+ }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
+
+ const Wrapper = shouldUseScrollView ? ScrollView : View;
+
+ const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {};
+
+ return (
+ setContainerHeight(e.nativeEvent.layout.height) : undefined}
+ onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
+ // eslint-disable-next-line react/forbid-component-props -- fsClass is required for FullStory session masking
+ fsClass={CONST.FULLSTORY.CLASS.UNMASK}
+ >
+
+ {renderIllustration()}
+
+
+ {!!title && !!description && (
+
+ {typeof title === 'string' ? {title} : title}
+ {shouldRenderHTMLDescription ? (
+
+
+
+ ) : (
+ {description}
+ )}
+ {secondaryDescription.length > 0 && {secondaryDescription}}
+ {children}
+
+ )}
+ {shouldShowDismissModalOption && (
+
+ )}
+ {!!helpText && (
+
+
+ );
+}
+
+export default FeatureTrainingContent;
+
+export type {FeatureTrainingContentProps};
diff --git a/src/components/FeatureTrainingModal.tsx b/src/components/FeatureTrainingModal.tsx
index 1e96566a5841..970831c88cc0 100644
--- a/src/components/FeatureTrainingModal.tsx
+++ b/src/components/FeatureTrainingModal.tsx
@@ -1,242 +1,71 @@
-import type {ImageContentFit} from 'expo-image';
-import type {SourceLoadEventPayload} from 'expo-video';
import React, {useEffect, useRef, useState} from 'react';
-import {Image, View} from 'react-native';
-// eslint-disable-next-line no-restricted-imports
-import type {ImageResizeMode, ImageSourcePropType, LayoutChangeEvent, ScrollView as RNScrollView, StyleProp, TextStyle, ViewStyle} from 'react-native';
-import {GestureHandlerRootView} from 'react-native-gesture-handler';
-import type {MergeExclusive} from 'type-fest';
-import useKeyboardState from '@hooks/useKeyboardState';
-import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset';
-import useLocalize from '@hooks/useLocalize';
-import useNetwork from '@hooks/useNetwork';
+import type {ViewStyle} from 'react-native';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
-import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
-import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import Accessibility from '@libs/Accessibility';
import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import TransitionTracker from '@libs/Navigation/TransitionTracker';
import variables from '@styles/variables';
-import {setNameValuePair} from '@userActions/User';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type IconAsset from '@src/types/utils/IconAsset';
-import Button from './Button';
-import CheckboxWithLabel from './CheckboxWithLabel';
-import FormAlertWithSubmitButton from './FormAlertWithSubmitButton';
-import ImageSVG from './ImageSVG';
-import type ImageSVGProps from './ImageSVG/types';
-import Lottie from './Lottie';
-import LottieAnimations from './LottieAnimations';
-import type DotLottieAnimation from './LottieAnimations/types';
+import type {FeatureTrainingContentProps} from './FeatureTrainingContent';
+import FeatureTrainingContent from './FeatureTrainingContent';
import Modal from './Modal';
-import OfflineIndicator from './OfflineIndicator';
-import RenderHTML from './RenderHTML';
-import ScrollView from './ScrollView';
-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 VideoStatus = 'video' | 'animation';
-
-type BaseFeatureTrainingModalProps = {
- /** The aspect ratio to preserve for the icon, video or animation */
- illustrationAspectRatio?: number;
-
- /** Style for the inner container of the animation */
- illustrationInnerContainerStyle?: StyleProp;
-
- /** Style for the outer container of the animation */
- illustrationOuterContainerStyle?: StyleProp;
-
- /** Title for the modal */
- title?: string | React.ReactNode;
-
- /** Describe what is showing */
- description?: string;
-
- /** Secondary description rendered with additional space */
- secondaryDescription?: string;
-
- /** Style for the title */
- titleStyles?: StyleProp;
-
- /** 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?: (willShowAgain: boolean) => void;
-
- /** A callback to call when modal closes */
- onClose?: () => void;
-
- /** Text to show on secondary button */
- helpText?: string;
-
- /** Link to navigate to when user wants to learn more */
- onHelp?: () => void;
-
- /** Styles for the content container */
- contentInnerContainerStyles?: StyleProp;
-
- /** Styles for the content outer container */
- contentOuterContainerStyles?: StyleProp;
-
+type FeatureTrainingModalProps = FeatureTrainingContentProps & {
/** Styles for the modal inner container */
modalInnerContainerStyle?: ViewStyle;
- /** Children to show below title and description and above buttons */
- children?: React.ReactNode;
-
- /** Modal width */
- width?: number;
-
/** Whether to disable the modal */
isModalDisabled?: boolean;
- /** Whether the modal image is a SVG */
- shouldRenderSVG?: boolean;
-
- /** Whether the modal description is written in HTML */
- shouldRenderHTMLDescription?: boolean;
-
- /** Whether the modal will be closed on confirm */
- shouldCloseOnConfirm?: boolean;
-
/** Whether the modal should avoid the keyboard */
avoidKeyboard?: boolean;
- /** Whether the modal content is scrollable */
- shouldUseScrollView?: boolean;
-
- /** Whether the modal is displaying a confirmation loading spinner (useful when fetching data from API during confirmation) */
- shouldShowConfirmationLoader?: boolean;
-
- /** Whether the user can confirm the tutorial while offline */
- canConfirmWhileOffline?: boolean;
-
/** Whether to navigate back when closing the modal */
shouldGoBack?: boolean;
/** Whether to call onHelp when modal is hidden completely */
shouldCallOnHelpWhenModalHidden?: boolean;
- /** Sentry label for the help/skip button */
- helpSentryLabel?: string;
-
- /** Sentry label for the confirm/submit button */
- confirmSentryLabel?: string;
-};
-
-type FeatureTrainingModalVideoProps = {
- /** Animation to show when video is unavailable. Useful when app is offline */
- animation?: DotLottieAnimation;
-
- /** Additional styles for the animation */
- animationStyle?: StyleProp;
-
- /** URL for the video */
- videoURL?: string;
+ /** Called when the modal is dismissed with "don't show again" checked */
+ onPersistDismiss?: () => void;
};
-type FeatureTrainingModalSVGProps = {
- /** Expensicon for the page */
- image: IconAsset;
-
- /** Determines how the image should be resized to fit its container */
- contentFitImage?: ImageContentFit;
-
- /** The width of the image */
- imageWidth?: ImageSVGProps['width'];
-
- /** The height of the image */
- imageHeight?: ImageSVGProps['height'];
-};
-
-// This page requires either an icon or a video/animation, but not both
-type FeatureTrainingModalProps = BaseFeatureTrainingModalProps & MergeExclusive;
-
-const LANDSCAPE_ILLUSTRATION_MAX_HEIGHT_TO_WINDOW_HEIGHT_RATIO = 0.7;
-
function FeatureTrainingModal({
- animation,
- animationStyle,
- illustrationInnerContainerStyle,
- illustrationOuterContainerStyle,
- videoURL,
- illustrationAspectRatio: illustrationAspectRatioProp,
- image,
- contentFitImage,
- width = variables.featureTrainingModalWidth,
- title = '',
- description = '',
- secondaryDescription = '',
- titleStyles,
- shouldShowDismissModalOption = false,
- confirmText = '',
- onConfirm = () => {},
- onClose = () => {},
- helpText = '',
- onHelp = () => {},
- children,
- contentInnerContainerStyles,
- contentOuterContainerStyles,
modalInnerContainerStyle,
- imageWidth,
- imageHeight,
isModalDisabled = true,
- shouldRenderSVG = true,
- shouldRenderHTMLDescription = false,
- shouldCloseOnConfirm = true,
avoidKeyboard = false,
- shouldUseScrollView: shouldUseScrollViewProp = false,
- shouldShowConfirmationLoader = false,
- canConfirmWhileOffline = true,
shouldGoBack = true,
shouldCallOnHelpWhenModalHidden = false,
- helpSentryLabel,
- confirmSentryLabel,
+ onConfirm,
+ onClose,
+ onHelp,
+ onWillShowAgainChange,
+ onPersistDismiss,
+ shouldShowDismissModalOption = false,
+ shouldUseScrollView: shouldUseScrollViewProp = false,
+ width = variables.featureTrainingModalWidth,
+ ...contentProps
}: FeatureTrainingModalProps) {
const styles = useThemeStyles();
- const StyleUtils = useStyleUtils();
- const {translate} = useLocalize();
- const isReduceMotionEnabled = Accessibility.useReducedMotion();
- const illustrations = useMemoizedLazyIllustrations(['Hands']);
const {onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
- const {windowHeight, windowWidth} = useWindowDimensions();
+ const {windowWidth, windowHeight} = useWindowDimensions();
+ const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeModeUtil(windowWidth, windowHeight);
const [isModalVisible, setIsModalVisible] = useState(false);
- const [willShowAgain, setWillShowAgain] = useState(true);
- const [videoStatus, setVideoStatus] = useState('video');
- const [isVideoStatusLocked, setIsVideoStatusLocked] = useState(false);
- const [illustrationAspectRatio, setIllustrationAspectRatio] = useState(illustrationAspectRatioProp ?? VIDEO_ASPECT_RATIO);
- const {shouldUseNarrowLayout} = useResponsiveLayout();
- const {isOffline} = useNetwork();
- const hasHelpButtonBeenPressed = useRef(false);
const pendingCloseRef = useRef(false);
- const scrollViewRef = useRef(null);
- const [containerHeight, setContainerHeight] = useState(0);
- const [contentHeight, setContentHeight] = useState(0);
- const insets = useSafeAreaInsets();
- const {isKeyboardActive} = useKeyboardState();
- const isInLandscapeMode = isInLandscapeModeUtil(windowWidth, windowHeight);
+ const hasHelpButtonBeenPressed = useRef(false);
+ const willShowAgainRef = useRef(true);
- const shouldUseScrollView = shouldUseScrollViewProp || isInLandscapeMode;
+ const handleWillShowAgainChange = (value: boolean) => {
+ willShowAgainRef.current = value;
+ onWillShowAgainChange?.(value);
+ };
useEffect(() => {
- // Transition tracker is used directly as we defer the opening of the modal until other animations are finished,
- // for which there is no higher-level API.
const handle = TransitionTracker.runAfterTransitions({
callback: () => {
if (!isModalDisabled) {
@@ -249,99 +78,6 @@ function FeatureTrainingModal({
return () => handle.cancel();
}, [isModalDisabled]);
- useEffect(() => {
- if (isVideoStatusLocked) {
- return;
- }
-
- if (isOffline) {
- setVideoStatus('animation');
- } else if (!isOffline) {
- setVideoStatus('video');
- setIsVideoStatusLocked(true);
- }
- }, [isOffline, isVideoStatusLocked]);
-
- const setAspectRatio = (event: SourceLoadEventPayload) => {
- const track = event.availableVideoTracks.at(0);
-
- if (!track) {
- return;
- }
-
- setIllustrationAspectRatio(track.size.width / track.size.height);
- };
-
- const renderIllustration = () => {
- const aspectRatio = illustrationAspectRatio || VIDEO_ASPECT_RATIO;
-
- return (
-
- {!!image &&
- (shouldRenderSVG ? (
-
- ) : (
-
- ))}
- {!!videoURL && videoStatus === 'video' && (
-
-
-
- )}
- {((!videoURL && !image) || (!!videoURL && videoStatus === 'animation')) && (
-
- {isReduceMotionEnabled && (animation ?? LottieAnimations.Hands) === LottieAnimations.Hands ? (
-
- ) : (
-
- )}
-
- )}
-
- );
- };
-
- const toggleWillShowAgain = () => setWillShowAgain((prevWillShowAgain) => !prevWillShowAgain);
-
const pendingCloseModalAction = () => {
Log.hmmm(`[FeatureTrainingModal] Modal hidden - shouldGoBack: ${shouldGoBack}, hasOnClose: ${!!onClose}`);
if (shouldGoBack) {
@@ -357,45 +93,23 @@ function FeatureTrainingModal({
};
const closeModal = () => {
- Log.hmmm(`[FeatureTrainingModal] closeModal called - willShowAgain: ${willShowAgain}, shouldGoBack: ${shouldGoBack}, hasOnClose: ${!!onClose}`);
-
- if (!willShowAgain) {
- Log.hmmm('[FeatureTrainingModal] Dismissing track training modal');
- setNameValuePair(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING, true, false);
- }
-
+ Log.hmmm(`[FeatureTrainingModal] closeModal called - shouldGoBack: ${shouldGoBack}, hasOnClose: ${!!onClose}`);
Log.hmmm('[FeatureTrainingModal] Setting modal invisible');
+ if (shouldShowDismissModalOption && !willShowAgainRef.current) {
+ onPersistDismiss?.();
+ }
pendingCloseRef.current = true;
setIsModalVisible(false);
};
- const closeAndConfirmModal = () => {
- Log.hmmm(`[FeatureTrainingModal] Button pressed - shouldCloseOnConfirm: ${shouldCloseOnConfirm}, hasOnConfirm: ${!!onConfirm}, willShowAgain: ${willShowAgain}`);
-
- if (shouldCloseOnConfirm) {
- Log.hmmm('[FeatureTrainingModal] Calling closeModal');
- closeModal();
- }
-
- if (onConfirm) {
- Log.hmmm('[FeatureTrainingModal] Calling onConfirm callback');
- onConfirm(willShowAgain);
- } else {
- Log.hmmm('[FeatureTrainingModal] No onConfirm callback provided');
- }
- };
-
- // Scrolls modal to the bottom when keyboard appears so the action buttons are visible.
- useEffect(() => {
- if (contentHeight <= containerHeight || onboardingIsMediumOrLargerScreenWidth || !shouldUseScrollView) {
+ const handleContentHelp = () => {
+ if (shouldCallOnHelpWhenModalHidden) {
+ setIsModalVisible(false);
+ hasHelpButtonBeenPressed.current = true;
return;
}
- scrollViewRef.current?.scrollToEnd({animated: false});
- }, [contentHeight, containerHeight, onboardingIsMediumOrLargerScreenWidth, shouldUseScrollView]);
-
- const Wrapper = shouldUseScrollView ? ScrollView : View;
-
- const wrapperStyles = shouldUseScrollView ? StyleUtils.getScrollableFeatureTrainingModalStyles(insets, isKeyboardActive) : {};
+ onHelp?.();
+ };
return (
- setContainerHeight(e.nativeEvent.layout.height) : undefined}
- onContentSizeChange={shouldUseScrollView ? (_w: number, h: number) => setContentHeight(h) : undefined}
- // Wrapper is either a View or ScrollView, which is also a View.
- // eslint-disable-next-line react/forbid-component-props
- fsClass={CONST.FULLSTORY.CLASS.UNMASK}
- >
-
- {renderIllustration()}
-
-
- {!!title && !!description && (
-
- {typeof title === 'string' ? {title} : title}
- {shouldRenderHTMLDescription ? (
-
-
-
- ) : (
- {description}
- )}
- {secondaryDescription.length > 0 && {secondaryDescription}}
- {children}
-
- )}
- {shouldShowDismissModalOption && (
-
- )}
- {!!helpText && (
-
-
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.tsx b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
index a79e1158e3aa..7864b2a33ac0 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.tsx
@@ -304,7 +304,7 @@ function AuthScreens() {
/>
-
-
-
-
-
+
+
+
);
}
diff --git a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts
index 70717d3fdd4e..ce939265b761 100644
--- a/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts
+++ b/src/libs/Navigation/AppNavigator/useRootNavigatorScreenOptions.ts
@@ -1,10 +1,12 @@
import type {StackCardInterpolationProps} from '@react-navigation/stack';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import Animations from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation';
+import Animations, {InternalPlatformAnimations} from '@libs/Navigation/PlatformStackNavigation/navigationOptions/animation';
import Presentation from '@libs/Navigation/PlatformStackNavigation/navigationOptions/presentation';
import type {PlatformStackNavigationOptions} from '@libs/Navigation/PlatformStackNavigation/types';
+import variables from '@styles/variables';
import CONST from '@src/CONST';
import hideKeyboardOnSwipe from './hideKeyboardOnSwipe';
import RHP_WEB_TRANSITION_SPEC from './RHPTransitionSpec';
@@ -13,6 +15,7 @@ import type {EnterAnimation} from './useModalCardStyleInterpolator';
type RootNavigatorScreenOptions = {
rightModalNavigator: PlatformStackNavigationOptions;
+ centeredModalNavigator: PlatformStackNavigationOptions;
basicModalNavigator: PlatformStackNavigationOptions;
splitNavigator: PlatformStackNavigationOptions;
fullScreen: PlatformStackNavigationOptions;
@@ -27,6 +30,7 @@ const commonScreenOptions: PlatformStackNavigationOptions = {
const useRootNavigatorScreenOptions = () => {
const StyleUtils = useStyleUtils();
+ const theme = useTheme();
const modalCardStyleInterpolator = useModalCardStyleInterpolator();
const {shouldUseNarrowLayout, onboardingIsMediumOrLargerScreenWidth} = useResponsiveLayout();
const themeStyles = useThemeStyles();
@@ -68,6 +72,21 @@ const useRootNavigatorScreenOptions = () => {
cardStyleInterpolator: (props: StackCardInterpolationProps) => modalCardStyleInterpolator({props, enter: onboardingEnter}),
},
},
+ centeredModalNavigator: {
+ presentation: Presentation.TRANSPARENT_MODAL,
+ native: {
+ contentStyle: {
+ ...StyleUtils.getBackgroundColorWithOpacityStyle(theme.overlay, variables.overlayOpacity),
+ },
+ animation: InternalPlatformAnimations.FADE,
+ },
+ web: {
+ cardStyle: {
+ ...StyleUtils.getBackgroundColorWithOpacityStyle(theme.overlay, variables.overlayOpacity),
+ },
+ animation: InternalPlatformAnimations.FADE,
+ },
+ },
splitNavigator: {
...commonScreenOptions,
// We need to turn off animation for the full screen to avoid delay when closing screens.
diff --git a/src/pages/TrackTrainingPage.tsx b/src/pages/TrackTrainingPage.tsx
index c1eb500acd04..d574a1949300 100644
--- a/src/pages/TrackTrainingPage.tsx
+++ b/src/pages/TrackTrainingPage.tsx
@@ -1,17 +1,23 @@
-import React, {useCallback} from 'react';
+import React from 'react';
import FeatureTrainingModal from '@components/FeatureTrainingModal';
import useLocalize from '@hooks/useLocalize';
import {openExternalLink} from '@userActions/Link';
+import {setNameValuePair} from '@userActions/User';
import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
const VIDEO_ASPECT_RATIO = 1560 / 1280;
function TrackTrainingPage() {
const {translate} = useLocalize();
- const onHelp = useCallback(() => {
+ const onHelp = () => {
openExternalLink(CONST.FEATURE_TRAINING[CONST.FEATURE_TRAINING.CONTENT_TYPES.TRACK_EXPENSE]?.LEARN_MORE_LINK);
- }, []);
+ };
+
+ const onPersistDismiss = () => {
+ setNameValuePair(ONYXKEYS.NVP_HAS_SEEN_TRACK_TRAINING, true, false);
+ };
return (
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 4b609d82ddcd..9566cbea9d9d 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -4,7 +4,7 @@ import type {LineLayerStyleProps} from '@rnmapbox/maps/src/utils/MapboxStyles';
import lodashClamp from 'lodash/clamp';
import type {LineLayer} from 'react-map-gl';
// eslint-disable-next-line no-restricted-imports
-import type {Animated, ImageStyle, TextStyle, ViewStyle} from 'react-native';
+import type {Animated, DimensionValue, ImageStyle, TextStyle, ViewStyle} from 'react-native';
import {Platform, StyleSheet} from 'react-native';
import type {PickerStyle} from 'react-native-picker-select';
import type {SharedValue} from 'react-native-reanimated';
@@ -6604,6 +6604,27 @@ const dynamicStyles = (theme: ThemeColors) =>
maxWidth: '100%',
}),
+ getCenteredModalOuterView: (shouldUseNarrowLayout: boolean) =>
+ ({
+ justifyContent: shouldUseNarrowLayout ? 'flex-end' : 'center',
+ }) as const,
+
+ getCenteredModalInnerView: (shouldUseNarrowLayout: boolean, width?: number, height?: DimensionValue) => {
+ const borderBottomRadius = shouldUseNarrowLayout ? 0 : variables.componentBorderRadiusLarge;
+
+ return {
+ width: shouldUseNarrowLayout ? '100%' : (width ?? variables.featureTrainingModalWidth),
+ // No default height - the card hugs its content (children must have intrinsic height)
+ height,
+ maxHeight: '100%' as const,
+ borderRadius: variables.componentBorderRadiusLarge,
+ borderBottomRightRadius: borderBottomRadius,
+ borderBottomLeftRadius: borderBottomRadius,
+ overflow: 'hidden' as const,
+ backgroundColor: theme.componentBG,
+ };
+ },
+
getTestToolsNavigatorOuterView: (shouldUseNarrowLayout: boolean) => ({
justifyContent: shouldUseNarrowLayout ? 'flex-end' : 'center',
}),