diff --git a/.js.env.example b/.js.env.example index 40f7123e053..3598cd6c2b4 100644 --- a/.js.env.example +++ b/.js.env.example @@ -150,6 +150,7 @@ export MM_MONEY_DEPOSIT_MIN_ASSET_BALANCE="0.01" export MM_MONEY_ENABLE_ACTIVITY_DETAILS="true" export MM_MONEY_ENABLE_ACTIVITY_DETAILS_BLOCKEXPLORER_LINK="true" export MM_MONEY_FIRST_TIME_DEPOSIT_ANIMATION_ENABLED="true" +export MM_MONEY_PARALLAX_ANIMATION_ENABLED="true" # Activates remote feature flag override mode. # Remote feature flag values won't be updated, diff --git a/app/animations/next_best_action_module_v1.riv b/app/animations/next_best_action_module_v1.riv new file mode 100644 index 00000000000..28e3a883ca5 Binary files /dev/null and b/app/animations/next_best_action_module_v1.riv differ diff --git a/app/component-library/components-temp/StepperCard/StepperCard.tsx b/app/component-library/components-temp/StepperCard/StepperCard.tsx index e9055c3a88a..e9966fe3e2c 100644 --- a/app/component-library/components-temp/StepperCard/StepperCard.tsx +++ b/app/component-library/components-temp/StepperCard/StepperCard.tsx @@ -57,11 +57,13 @@ const StepperCard = ({ {/* Image */} - + {step.media ?? ( + + )} diff --git a/app/component-library/components-temp/StepperCard/StepperCard.types.ts b/app/component-library/components-temp/StepperCard/StepperCard.types.ts index b06a5f06c5e..c27c3da6668 100644 --- a/app/component-library/components-temp/StepperCard/StepperCard.types.ts +++ b/app/component-library/components-temp/StepperCard/StepperCard.types.ts @@ -1,3 +1,4 @@ +import { ReactNode } from 'react'; import { ImageSourcePropType } from 'react-native'; export interface StepperCardCta { @@ -20,6 +21,11 @@ export interface StepperCardStep { */ descriptionTooltipAccessibilityLabel?: string; image: ImageSourcePropType; + /** + * Optional custom media rendered in the image slot in place of `image` + * (e.g. an animated graphic). Falls back to `image` when not provided. + */ + media?: ReactNode; primaryCta: StepperCardCta; secondaryCta?: StepperCardCta; } diff --git a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx index 3057e849a2f..e11b6c8d333 100644 --- a/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx +++ b/app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx @@ -104,6 +104,14 @@ jest.mock('../../hooks/useMoneyEarnableTokens', () => ({ useMoneyEarnableTokens: () => mockUseMoneyEarnableTokens(), })); +// Animated Rive graphic pulls in device sensors; not exercised by these tests. +jest.mock('../../components/MoneyNextBestActionParallax', () => ({ + __esModule: true, + default: () => null, + PARALLAX_ARTBOARD_FUND: 'Parallax Block 1', + PARALLAX_ARTBOARD_CARD: 'Parallax Block 2', +})); + jest.mock('../../hooks/useMoneyAccountTransactions', () => ({ useMoneyAccountTransactions: jest.fn(), })); diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.styles.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.styles.ts new file mode 100644 index 00000000000..cbe6c6b0e25 --- /dev/null +++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.styles.ts @@ -0,0 +1,10 @@ +import { StyleSheet } from 'react-native'; + +const styles = StyleSheet.create({ + media: { + width: '100%', + height: '100%', + }, +}); + +export default styles; diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.test.tsx b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.test.tsx new file mode 100644 index 00000000000..f37e7659c9d --- /dev/null +++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.test.tsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import MoneyNextBestActionParallax from './MoneyNextBestActionParallax'; +import { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds'; +import { useReduceMotion } from '../../hooks/useReduceMotion'; +import { useDeviceOrientation } from '../../hooks/useDeviceOrientation'; +import fallbackImage from '../../../../../images/money-onboarding-stepper-step-1.png'; + +const mockSetXValue = jest.fn(); +const mockSetYValue = jest.fn(); +const mockRefCallback = jest.fn(); +const mockRiveInstance = {}; +const mockOnErrorRef: { current?: (error: { message: string }) => void } = {}; +const mockRiveProps: { current?: { artboardName?: string } } = {}; + +jest.mock('rive-react-native', () => { + const ReactActual = jest.requireActual('react'); + const { View: RNView } = jest.requireActual('react-native'); + return { + __esModule: true, + AutoBind: jest.fn(() => ({})), + Fit: { Contain: 'contain' }, + useRive: () => [mockRefCallback, mockRiveInstance], + useRiveNumber: (_instance: unknown, path: string) => [ + undefined, + path === 'xValue' ? mockSetXValue : mockSetYValue, + ], + default: (props: { + testID?: string; + artboardName?: string; + onError?: (error: { message: string }) => void; + }) => { + mockOnErrorRef.current = props.onError; + mockRiveProps.current = { artboardName: props.artboardName }; + return ReactActual.createElement(RNView, { testID: props.testID }); + }, + }; +}); + +const mockUseSelector = jest.fn(); +jest.mock('react-redux', () => ({ + useSelector: (selector: unknown) => mockUseSelector(selector), +})); + +jest.mock('../../hooks/useReduceMotion', () => ({ + useReduceMotion: jest.fn(), +})); + +jest.mock('../../hooks/useDeviceOrientation', () => ({ + useDeviceOrientation: jest.fn(), +})); + +const mockUseReduceMotion = useReduceMotion as jest.Mock; +const mockUseDeviceOrientation = useDeviceOrientation as jest.Mock; + +describe('MoneyNextBestActionParallax', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOnErrorRef.current = undefined; + mockRiveProps.current = undefined; + mockUseSelector.mockReturnValue(true); + mockUseReduceMotion.mockReturnValue(false); + }); + + it('renders the Rive animation when enabled and reduce motion is off', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(MoneyNextBestActionParallaxTestIds.RIVE), + ).toBeOnTheScreen(); + expect( + queryByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE), + ).toBeNull(); + }); + + it('renders the gradient background behind the Rive when animating', () => { + const { getByTestId } = render( + , + ); + + expect( + getByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND), + ).toBeOnTheScreen(); + }); + + it('passes the given artboard name through to Rive', () => { + render( + , + ); + + expect(mockRiveProps.current?.artboardName).toBe('Parallax Block 2'); + }); + + it('does not render the gradient background behind the fallback image', () => { + mockUseReduceMotion.mockReturnValue(true); + + const { queryByTestId } = render( + , + ); + + expect( + queryByTestId(MoneyNextBestActionParallaxTestIds.BACKGROUND), + ).toBeNull(); + }); + + it('renders the fallback image when the feature flag is disabled', () => { + mockUseSelector.mockReturnValue(false); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE), + ).toBeOnTheScreen(); + expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull(); + }); + + it('renders the fallback image (with the provided source) when reduce motion is enabled', () => { + mockUseReduceMotion.mockReturnValue(true); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect( + getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE).props.source, + ).toBe(fallbackImage); + expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull(); + }); + + it('enables the device tilt callback when animating', () => { + render( + , + ); + + expect(mockUseDeviceOrientation).toHaveBeenCalledWith( + expect.any(Function), + { + enabled: true, + }, + ); + }); + + it('disables the device tilt callback when not animating', () => { + mockUseReduceMotion.mockReturnValue(true); + + render( + , + ); + + expect(mockUseDeviceOrientation).toHaveBeenCalledWith( + expect.any(Function), + { + enabled: false, + }, + ); + }); + + it('drives the bound Rive number properties from mapped tilt values', () => { + render( + , + ); + + const applyTilt = mockUseDeviceOrientation.mock.calls[0][0] as ( + x: number, + y: number, + ) => void; + + act(() => applyTilt(0.5, -0.5)); + + expect(mockSetXValue).toHaveBeenCalledWith(75); + expect(mockSetYValue).toHaveBeenCalledWith(25); + }); + + it('falls back to the static image when Rive reports an error', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + act(() => mockOnErrorRef.current?.({ message: 'boom' })); + + expect( + getByTestId(MoneyNextBestActionParallaxTestIds.STATIC_IMAGE), + ).toBeOnTheScreen(); + expect(queryByTestId(MoneyNextBestActionParallaxTestIds.RIVE)).toBeNull(); + }); +}); diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.testIds.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.testIds.ts new file mode 100644 index 00000000000..a0c79fa33b0 --- /dev/null +++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.testIds.ts @@ -0,0 +1,6 @@ +export const MoneyNextBestActionParallaxTestIds = { + CONTAINER: 'money-next-best-action-parallax-container', + RIVE: 'money-next-best-action-parallax-rive', + BACKGROUND: 'money-next-best-action-parallax-background', + STATIC_IMAGE: 'money-next-best-action-parallax-static-image', +} as const; diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.tsx b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.tsx new file mode 100644 index 00000000000..f50f86e2bef --- /dev/null +++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/MoneyNextBestActionParallax.tsx @@ -0,0 +1,132 @@ +import React, { useCallback, useState } from 'react'; +import { + Image, + ImageSourcePropType, + StyleProp, + StyleSheet, + ViewStyle, +} from 'react-native'; +import { useSelector } from 'react-redux'; +import { Box } from '@metamask/design-system-react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import Rive, { + AutoBind, + Fit, + RNRiveError, + useRive, + useRiveNumber, +} from 'rive-react-native'; +import { createProjectLogger } from '@metamask/utils'; +import { selectMoneyParallaxAnimationEnabledFlag } from '../../selectors/featureFlags'; +import { useReduceMotion } from '../../hooks/useReduceMotion'; +import { useDeviceOrientation } from '../../hooks/useDeviceOrientation'; +import { tiltToParallaxValue } from './parallax'; +import NextBestActionParallaxAnimation from '../../../../../animations/next_best_action_module_v1.riv'; +import styles from './MoneyNextBestActionParallax.styles'; +import { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds'; + +const log = createProjectLogger('money-parallax'); + +// Artboard names inside next_best_action_module_v1.riv, one per onboarding step. +export const PARALLAX_ARTBOARD_FUND = 'Parallax Block 1'; +export const PARALLAX_ARTBOARD_CARD = 'Parallax Block 2'; + +const RIVE_PROPERTY_X = 'xValue'; +const RIVE_PROPERTY_Y = 'yValue'; + +// The Rive artboard is transparent — the card's gradient background (sampled +// from the design) is rendered behind it. +const PARALLAX_BACKGROUND_COLORS = [ + 'rgb(24, 1, 101)', + 'rgb(40, 43, 142)', + 'rgb(57, 93, 191)', +]; + +interface MoneyNextBestActionParallaxProps { + /** Rive artboard to render (see PARALLAX_ARTBOARD_* constants). */ + artboardName: string; + /** Static image shown when the animation is unavailable or disabled. */ + fallbackImage: ImageSourcePropType; + style?: StyleProp; + testID?: string; +} + +const MoneyNextBestActionParallax = ({ + artboardName, + fallbackImage, + style, + testID, +}: MoneyNextBestActionParallaxProps) => { + const flagEnabled = useSelector(selectMoneyParallaxAnimationEnabledFlag); + const reduceMotion = useReduceMotion(); + const [hasRiveError, setHasRiveError] = useState(false); + const [riveRef, riveInstance] = useRive(); + const [, setXValue] = useRiveNumber(riveInstance, RIVE_PROPERTY_X); + const [, setYValue] = useRiveNumber(riveInstance, RIVE_PROPERTY_Y); + + const animate = flagEnabled && !reduceMotion && !hasRiveError; + + const applyTilt = useCallback( + (x: number, y: number) => { + if (!riveInstance) return; + setXValue(tiltToParallaxValue(x)); + setYValue(tiltToParallaxValue(y)); + }, + [riveInstance, setXValue, setYValue], + ); + + useDeviceOrientation(applyTilt, { enabled: animate }); + + const handleError = useCallback((riveError: RNRiveError) => { + log(`Rive error: ${riveError.message}`); + setHasRiveError(true); + }, []); + + let content: React.ReactNode; + if (animate) { + content = ( + <> + + + + ); + } else { + content = ( + + ); + } + + return ( + + {content} + + ); +}; + +export default MoneyNextBestActionParallax; diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/index.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/index.ts new file mode 100644 index 00000000000..4a317e3416e --- /dev/null +++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/index.ts @@ -0,0 +1,6 @@ +export { + default, + PARALLAX_ARTBOARD_FUND, + PARALLAX_ARTBOARD_CARD, +} from './MoneyNextBestActionParallax'; +export { MoneyNextBestActionParallaxTestIds } from './MoneyNextBestActionParallax.testIds'; diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.test.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.test.ts new file mode 100644 index 00000000000..b59a2f5a4c2 --- /dev/null +++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.test.ts @@ -0,0 +1,37 @@ +import { + PARALLAX_REST_VALUE, + PARALLAX_TILT_AMPLITUDE, + tiltToParallaxValue, +} from './parallax'; + +describe('tiltToParallaxValue', () => { + it('maps a flat device to the resting (centred) value', () => { + expect(tiltToParallaxValue(0)).toBe(PARALLAX_REST_VALUE); + }); + + it('maps full positive tilt to rest + amplitude', () => { + expect(tiltToParallaxValue(1)).toBe( + PARALLAX_REST_VALUE + PARALLAX_TILT_AMPLITUDE, + ); + }); + + it('maps full negative tilt to rest - amplitude', () => { + expect(tiltToParallaxValue(-1)).toBe( + PARALLAX_REST_VALUE - PARALLAX_TILT_AMPLITUDE, + ); + }); + + it('maps a partial tilt linearly around the resting value', () => { + expect(tiltToParallaxValue(0.5)).toBe(75); + expect(tiltToParallaxValue(-0.5)).toBe(25); + }); + + it('clamps values beyond the normalized range', () => { + expect(tiltToParallaxValue(2)).toBe( + PARALLAX_REST_VALUE + PARALLAX_TILT_AMPLITUDE, + ); + expect(tiltToParallaxValue(-2)).toBe( + PARALLAX_REST_VALUE - PARALLAX_TILT_AMPLITUDE, + ); + }); +}); diff --git a/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.ts b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.ts new file mode 100644 index 00000000000..bb4c9b91c42 --- /dev/null +++ b/app/components/UI/Money/components/MoneyNextBestActionParallax/parallax.ts @@ -0,0 +1,23 @@ +/** + * Rive `xValue` / `yValue` (view model "Main") drive the parallax layers. + * The authored resting value is 50 (centred); the `x0`/`x100`/`y0`/`y100` + * timelines map 0 → one extreme and 100 → the other. + */ +export const PARALLAX_REST_VALUE = 50; + +/** Travel from the resting value at full device tilt. 50 → design-native 0..100. */ +export const PARALLAX_TILT_AMPLITUDE = 50; + +/** + * Maps a normalized device-tilt value (from `useDeviceOrientation`, in the + * [-1, 1] range) onto the Rive value, swinging symmetrically around the resting + * value. + * + * A flat device (tilt 0) yields the rest value (centred); full tilt yields + * `rest ± PARALLAX_TILT_AMPLITUDE`. The input is clamped so a sensor spike can + * never push a layer past its intended travel. + */ +export function tiltToParallaxValue(tilt: number): number { + const clamped = Math.min(1, Math.max(-1, tilt)); + return PARALLAX_REST_VALUE + clamped * PARALLAX_TILT_AMPLITUDE; +} diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx index 973ab3e4554..1634d524a30 100644 --- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx +++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.test.tsx @@ -40,6 +40,11 @@ jest.mock('../../hooks/useMoneyAnalytics', () => ({ useMoneyAnalytics: jest.fn(), })); +jest.mock('../MoneyNextBestActionParallax', () => ({ + __esModule: true, + default: () => null, +})); + jest.mock('@metamask/design-system-twrnc-preset', () => { const tw = (..._args: unknown[]) => ({}); tw.style = jest.fn(() => ({})); diff --git a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx index 8d6fba783da..cbc884a854e 100644 --- a/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx +++ b/app/components/UI/Money/components/MoneyOnboardingCard/MoneyOnboardingCard.tsx @@ -10,6 +10,10 @@ import { isPositiveNumber } from '../../utils/number'; import StepperCard, { type StepperCardStep, } from '../../../../../component-library/components-temp/StepperCard'; +import MoneyNextBestActionParallax, { + PARALLAX_ARTBOARD_CARD, + PARALLAX_ARTBOARD_FUND, +} from '../MoneyNextBestActionParallax'; import { useMoneyAccountDeposit } from '../../hooks/useMoneyAccount'; import useMoneyAccountBalance from '../../hooks/useMoneyAccountBalance'; import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; @@ -268,8 +272,21 @@ const MoneyOnboardingCard = () => { onPress: handleStep1CtaPressed, }, image: moneyOnboardingStepperStep1, + media: ( + + ), }; + const cardStepMedia = ( + + ); + // Case 1: Cardholder, or authenticated with a card not yet linked. const step2: StepperCardStep = shouldShowLinkCardAction ? { @@ -301,6 +318,7 @@ const MoneyOnboardingCard = () => { ), }, image: moneyOnboardingStepperStep2, + media: cardStepMedia, } : // No MetaMask card yet. { @@ -332,6 +350,7 @@ const MoneyOnboardingCard = () => { ), }, image: moneyOnboardingStepperStep2, + media: cardStepMedia, }; return [step1, step2]; diff --git a/app/components/UI/Money/hooks/useDeviceOrientation.test.ts b/app/components/UI/Money/hooks/useDeviceOrientation.test.ts new file mode 100644 index 00000000000..5dc4fbc02b5 --- /dev/null +++ b/app/components/UI/Money/hooks/useDeviceOrientation.test.ts @@ -0,0 +1,207 @@ +import { renderHook } from '@testing-library/react-native'; +import { + accelerationToTilt, + useDeviceOrientation, +} from './useDeviceOrientation'; + +const mockSubscribe = jest.fn(); +const mockUnsubscribe = jest.fn(); +const mockSetUpdateIntervalForType = jest.fn(); + +jest.mock('react-native-sensors', () => ({ + accelerometer: { + subscribe: (observer: { + next: (value: { x: number; y: number; z: number }) => void; + error: () => void; + }) => mockSubscribe(observer), + }, + SensorTypes: { accelerometer: 'accelerometer' }, + setUpdateIntervalForType: (...args: unknown[]) => + mockSetUpdateIntervalForType(...args), +})); + +const mockGetTotalMemorySync = jest.fn(); +jest.mock('react-native-device-info', () => ({ + getTotalMemorySync: () => mockGetTotalMemorySync(), +})); + +const TWO_GB = 2 * 1024 * 1024 * 1024; +const FOUR_GB = 4 * 1024 * 1024 * 1024; +const G = 9.81; + +describe('accelerationToTilt', () => { + it('is neutral (0) on both axes at the natural 45° holding angle', () => { + // pitch 45°: y === hypot(x, z); roll 0: x === 0. + const tilt = accelerationToTilt(0, 1, 1); + expect(tilt.x).toBeCloseTo(0); + expect(tilt.y).toBeCloseTo(0); + }); + + it('reads a negative pitch when the phone lies flat', () => { + const tilt = accelerationToTilt(0, 0, G); + expect(tilt.y).toBe(-1); // pitch 0 is well below the 45° neutral + expect(tilt.x).toBeCloseTo(0); + }); + + it('reads a positive roll when tilted right and negative when tilted left', () => { + expect(accelerationToTilt(1, 0, 1).x).toBeGreaterThan(0); + expect(accelerationToTilt(-1, 0, 1).x).toBeLessThan(0); + }); + + it('clamps both axes to the [-1, 1] range', () => { + const right = accelerationToTilt(G, 0, 0); + expect(right.x).toBeLessThanOrEqual(1); + expect(right.x).toBeGreaterThanOrEqual(-1); + const upright = accelerationToTilt(0, G, 0); + expect(upright.y).toBeLessThanOrEqual(1); + expect(upright.y).toBeGreaterThanOrEqual(-1); + }); +}); + +describe('useDeviceOrientation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSubscribe.mockReturnValue({ unsubscribe: mockUnsubscribe }); + mockGetTotalMemorySync.mockReturnValue(FOUR_GB); + }); + + it('subscribes to the accelerometer when enabled', () => { + renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true })); + + expect(mockSubscribe).toHaveBeenCalledTimes(1); + }); + + it('subscribes by default when no options are provided', () => { + renderHook(() => useDeviceOrientation(jest.fn())); + + expect(mockSubscribe).toHaveBeenCalledTimes(1); + }); + + it('does not subscribe when disabled', () => { + renderHook(() => useDeviceOrientation(jest.fn(), { enabled: false })); + + expect(mockSubscribe).not.toHaveBeenCalled(); + }); + + it('subscribes when enabled flips from false to true', () => { + const { rerender } = renderHook( + ({ enabled }) => useDeviceOrientation(jest.fn(), { enabled }), + { initialProps: { enabled: false } }, + ); + + expect(mockSubscribe).not.toHaveBeenCalled(); + + rerender({ enabled: true }); + + expect(mockSubscribe).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes when enabled flips from true to false', () => { + const { rerender } = renderHook( + ({ enabled }) => useDeviceOrientation(jest.fn(), { enabled }), + { initialProps: { enabled: true } }, + ); + + rerender({ enabled: false }); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('smooths accelerometer samples toward the target tilt', () => { + const onOrientation = jest.fn(); + renderHook(() => useDeviceOrientation(onOrientation, { enabled: true })); + + const observer = mockSubscribe.mock.calls[0][0]; + // Flat phone: target y is -1. Feed repeatedly so the low-pass converges. + for (let i = 0; i < 100; i++) { + observer.next({ x: 0, y: 0, z: G }); + } + + const [x, y] = + onOrientation.mock.calls[onOrientation.mock.calls.length - 1]; + expect(x).toBeCloseTo(0); + expect(y).toBeCloseTo(-1); + }); + + it('emits a value smaller than the target on the first sample (low-pass)', () => { + const onOrientation = jest.fn(); + renderHook(() => useDeviceOrientation(onOrientation, { enabled: true })); + + const observer = mockSubscribe.mock.calls[0][0]; + observer.next({ x: 0, y: 0, z: G }); + + const [, y] = onOrientation.mock.calls[0]; + expect(y).toBeLessThan(0); + expect(y).toBeGreaterThan(-1); // not yet fully converged to -1 + }); + + it('unsubscribes on unmount', () => { + const { unmount } = renderHook(() => + useDeviceOrientation(jest.fn(), { enabled: true }), + ); + + unmount(); + + expect(mockUnsubscribe).toHaveBeenCalledTimes(1); + }); + + it('uses a 60Hz interval on a standard device', () => { + mockGetTotalMemorySync.mockReturnValue(FOUR_GB); + + renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true })); + + expect(mockSetUpdateIntervalForType).toHaveBeenCalledWith( + 'accelerometer', + 1000 / 60, + ); + }); + + it('uses a 30Hz interval on a low-end device', () => { + mockGetTotalMemorySync.mockReturnValue(TWO_GB); + + renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true })); + + expect(mockSetUpdateIntervalForType).toHaveBeenCalledWith( + 'accelerometer', + 1000 / 30, + ); + }); + + it('does not resubscribe when only the callback identity changes', () => { + const { rerender } = renderHook< + void, + { cb: (x: number, y: number) => void } + >(({ cb }) => useDeviceOrientation(cb, { enabled: true }), { + initialProps: { cb: jest.fn() }, + }); + + rerender({ cb: jest.fn() }); + + expect(mockSubscribe).toHaveBeenCalledTimes(1); + }); + + it('invokes the latest callback after it changes', () => { + const first = jest.fn(); + const second = jest.fn(); + const { rerender } = renderHook( + ({ cb }) => useDeviceOrientation(cb, { enabled: true }), + { initialProps: { cb: first } }, + ); + + rerender({ cb: second }); + + const observer = mockSubscribe.mock.calls[0][0]; + observer.next({ x: 0, y: 0, z: G }); + + expect(second).toHaveBeenCalledTimes(1); + expect(first).not.toHaveBeenCalled(); + }); + + it('ignores sensor errors without throwing', () => { + renderHook(() => useDeviceOrientation(jest.fn(), { enabled: true })); + + const observer = mockSubscribe.mock.calls[0][0]; + + expect(() => observer.error()).not.toThrow(); + }); +}); diff --git a/app/components/UI/Money/hooks/useDeviceOrientation.ts b/app/components/UI/Money/hooks/useDeviceOrientation.ts new file mode 100644 index 00000000000..6578a69dcd3 --- /dev/null +++ b/app/components/UI/Money/hooks/useDeviceOrientation.ts @@ -0,0 +1,94 @@ +import { useEffect, useRef } from 'react'; +import { + accelerometer, + SensorTypes, + setUpdateIntervalForType, +} from 'react-native-sensors'; +import { getTotalMemorySync } from 'react-native-device-info'; + +const DEG = Math.PI / 180; +// Natural holding angle: people hold the phone tilted ~this far back from +// horizontal. Treated as the neutral (rest) pitch, so holding normally sits at +// the centre of the parallax rather than an extreme. +const NEUTRAL_PITCH = 45 * DEG; +// Rotation away from neutral (per axis) that maps to a full ±1 tilt. +const PITCH_TRAVEL = 30 * DEG; +const ROLL_TRAVEL = 30 * DEG; +// Low-pass factor: higher = snappier but noisier, lower = smoother but laggier. +const SMOOTHING = 0.2; +const HZ_LOW_END = 30; +const HZ_DEFAULT = 60; +const ONE_GIGABYTE = 1024 * 1024 * 1024; + +const clamp = (value: number, min: number, max: number): number => + Math.min(Math.max(value, min), max); + +const isLowEndDevice = (): boolean => getTotalMemorySync() <= 2 * ONE_GIGABYTE; + +/** + * Converts a gravity vector (accelerometer reading) into a normalized, + * clamped [-1, 1] tilt per axis, measured as absolute pitch/roll relative to + * the natural holding position. Pure so it can be unit-tested directly. + */ +export function accelerationToTilt( + x: number, + y: number, + z: number, +): { x: number; y: number } { + const pitch = Math.atan2(y, Math.hypot(x, z)); + const roll = Math.atan2(x, Math.hypot(y, z)); + return { + x: clamp(roll / ROLL_TRAVEL, -1, 1), + y: clamp((pitch - NEUTRAL_PITCH) / PITCH_TRAVEL, -1, 1), + }; +} + +interface UseDeviceOrientationOptions { + enabled?: boolean; +} + +/** + * Reports device tilt as a normalized, clamped [-1, 1] value per axis, derived + * from the accelerometer's absolute orientation (gravity) with a natural-hold + * neutral. Unlike integrating the gyroscope, this is drift-free and returns to + * the same neutral for the same physical orientation. + * + * @param onOrientation - receives (x, y) roll/pitch in the [-1, 1] range. + */ +export function useDeviceOrientation( + onOrientation: (x: number, y: number) => void, + options?: UseDeviceOrientationOptions, +): void { + const enabled = options?.enabled ?? true; + const onOrientationRef = useRef(onOrientation); + const smoothed = useRef({ x: 0, y: 0 }); + + useEffect(() => { + onOrientationRef.current = onOrientation; + }, [onOrientation]); + + useEffect(() => { + // Do no sensor work (including reading device memory) while disabled — e.g. + // when the feature flag is off or reduce-motion is on. + if (!enabled) return undefined; + + const hz = isLowEndDevice() ? HZ_LOW_END : HZ_DEFAULT; + setUpdateIntervalForType(SensorTypes.accelerometer, 1000 / hz); + + smoothed.current = { x: 0, y: 0 }; + + const subscription = accelerometer.subscribe({ + next: ({ x, y, z }) => { + const tilt = accelerationToTilt(x, y, z); + smoothed.current = { + x: smoothed.current.x + SMOOTHING * (tilt.x - smoothed.current.x), + y: smoothed.current.y + SMOOTHING * (tilt.y - smoothed.current.y), + }; + onOrientationRef.current(smoothed.current.x, smoothed.current.y); + }, + error: () => undefined, + }); + + return () => subscription.unsubscribe(); + }, [enabled]); +} diff --git a/app/components/UI/Money/hooks/useReduceMotion.test.ts b/app/components/UI/Money/hooks/useReduceMotion.test.ts new file mode 100644 index 00000000000..90dd930a6af --- /dev/null +++ b/app/components/UI/Money/hooks/useReduceMotion.test.ts @@ -0,0 +1,70 @@ +import { AccessibilityInfo } from 'react-native'; +import { renderHook, act, waitFor } from '@testing-library/react-native'; +import { useReduceMotion } from './useReduceMotion'; + +describe('useReduceMotion', () => { + const mockRemove = jest.fn(); + let capturedListener: ((enabled: boolean) => void) | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + capturedListener = undefined; + + jest + .spyOn(AccessibilityInfo, 'isReduceMotionEnabled') + .mockResolvedValue(false); + + jest + .spyOn(AccessibilityInfo, 'addEventListener') + .mockImplementation((_event, listener) => { + capturedListener = listener as unknown as (enabled: boolean) => void; + return { remove: mockRemove } as never; + }); + }); + + it('defaults to true before the initial value resolves', () => { + jest + .spyOn(AccessibilityInfo, 'isReduceMotionEnabled') + .mockReturnValue(new Promise(() => undefined)); + + const { result } = renderHook(() => useReduceMotion()); + + expect(result.current).toBe(true); + }); + + it('resolves to false once the check reports reduce motion is off', async () => { + const { result } = renderHook(() => useReduceMotion()); + + await waitFor(() => expect(result.current).toBe(false)); + }); + + it('resolves to true when reduce motion is enabled', async () => { + jest + .spyOn(AccessibilityInfo, 'isReduceMotionEnabled') + .mockResolvedValue(true); + + const { result } = renderHook(() => useReduceMotion()); + + await waitFor(() => expect(result.current).toBe(true)); + }); + + it('updates when the reduceMotionChanged event fires', async () => { + const { result } = renderHook(() => useReduceMotion()); + + await waitFor(() => expect(result.current).toBe(false)); + + act(() => { + capturedListener?.(true); + }); + + expect(result.current).toBe(true); + }); + + it('removes the listener on unmount', () => { + const { unmount } = renderHook(() => useReduceMotion()); + + unmount(); + + expect(mockRemove).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/components/UI/Money/hooks/useReduceMotion.ts b/app/components/UI/Money/hooks/useReduceMotion.ts new file mode 100644 index 00000000000..e40e3d39116 --- /dev/null +++ b/app/components/UI/Money/hooks/useReduceMotion.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { AccessibilityInfo } from 'react-native'; + +export function useReduceMotion(): boolean { + // Default to true so callers don't animate before the async check resolves — + // otherwise a user with reduce-motion enabled briefly sees the animation. + const [reduceMotion, setReduceMotion] = useState(true); + + useEffect(() => { + let mounted = true; + + AccessibilityInfo.isReduceMotionEnabled().then((enabled) => { + if (mounted) setReduceMotion(enabled); + }); + + const subscription = AccessibilityInfo.addEventListener( + 'reduceMotionChanged', + setReduceMotion, + ); + + return () => { + mounted = false; + subscription.remove(); + }; + }, []); + + return reduceMotion; +} diff --git a/app/components/UI/Money/selectors/featureFlags.test.ts b/app/components/UI/Money/selectors/featureFlags.test.ts index 48b453968a7..1897ff29a4f 100644 --- a/app/components/UI/Money/selectors/featureFlags.test.ts +++ b/app/components/UI/Money/selectors/featureFlags.test.ts @@ -12,6 +12,7 @@ import { selectMoneyCardActivityCashbackMultisendContracts, selectMoneyNoFeeDepositTokens, selectMoneyFirstTimeDepositAnimationEnabledFlag, + selectMoneyParallaxAnimationEnabledFlag, selectMoneyVaultApyRemoteConfig, } from './featureFlags'; import { DEFAULT_MONEY_CARD_ACTIVITY_CASHBACK_MULTISEND_CONTRACTS } from '../utils/accountsApi'; @@ -554,6 +555,91 @@ describe('selectMoneyFirstTimeDepositAnimationEnabledFlag', () => { }); }); +describe('selectMoneyParallaxAnimationEnabledFlag', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns true when remote flag is enabled and version requirement is met', () => { + mockedValidate.mockReturnValue(true); + + const state = createState({ + earnMoneyParallaxAnimationEnabled: { + enabled: true, + minimumVersion: '1.0.0', + }, + }); + + const result = selectMoneyParallaxAnimationEnabledFlag(state as never); + + expect(result).toBe(true); + }); + + it('returns false when remote flag is disabled', () => { + mockedValidate.mockReturnValue(false); + + const state = createState({ + earnMoneyParallaxAnimationEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + const result = selectMoneyParallaxAnimationEnabledFlag(state as never); + + expect(result).toBe(false); + }); + + it('defaults to false when remote flag returns undefined and env is unset', () => { + mockedValidate.mockReturnValue(undefined); + delete process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED; + + const state = createState({ + _unique: 'parallax-default-off', + }); + + const result = selectMoneyParallaxAnimationEnabledFlag(state as never); + + expect(result).toBe(false); + }); + + it('returns true when env var is set to true and remote is undefined', () => { + mockedValidate.mockReturnValue(undefined); + process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED = 'true'; + + const state = createState({ + _unique: 'parallax-env-true', + }); + + const result = selectMoneyParallaxAnimationEnabledFlag(state as never); + + expect(result).toBe(true); + }); + + it('remote flag takes precedence over env var', () => { + mockedValidate.mockReturnValue(false); + process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED = 'true'; + + const state = createState({ + earnMoneyParallaxAnimationEnabled: { + enabled: false, + minimumVersion: '1.0.0', + }, + }); + + const result = selectMoneyParallaxAnimationEnabledFlag(state as never); + + expect(result).toBe(false); + }); +}); + describe('selectMoneyVaultApyRemoteConfig', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/app/components/UI/Money/selectors/featureFlags.ts b/app/components/UI/Money/selectors/featureFlags.ts index 0d910b0b156..f3daa56db91 100644 --- a/app/components/UI/Money/selectors/featureFlags.ts +++ b/app/components/UI/Money/selectors/featureFlags.ts @@ -33,6 +33,23 @@ export const selectMoneyEnableActivityDetailsFlag = createSelector( }, ); +/** + * Selects whether the tilt-driven parallax animation is shown on the Money + * onboarding "Next Best Action" card. Defaults to off (opt-in) so the feature + * stays disabled unless the remote flag or MM_MONEY_PARALLAX_ANIMATION_ENABLED + * turns it on; the static image is used otherwise, when reduce-motion is + * enabled, or when Rive fails to load. + */ +export const selectMoneyParallaxAnimationEnabledFlag = createSelector( + selectRemoteFeatureFlags, + (remoteFeatureFlags) => { + const remoteFlag = + remoteFeatureFlags?.earnMoneyParallaxAnimationEnabled as unknown as VersionGatedFeatureFlag; + const local = process.env.MM_MONEY_PARALLAX_ANIMATION_ENABLED === 'true'; + return validatedVersionGatedFeatureFlag(remoteFlag) ?? local; + }, +); + /** * Selects whether the block explorer link is shown in Money activity detail * views. When off, the "View on block explorer" button is hidden. diff --git a/tests/feature-flags/feature-flag-registry.ts b/tests/feature-flags/feature-flag-registry.ts index e233b494dcb..ba1d6aa131a 100644 --- a/tests/feature-flags/feature-flag-registry.ts +++ b/tests/feature-flags/feature-flag-registry.ts @@ -3334,6 +3334,13 @@ export const FEATURE_FLAG_REGISTRY: Record = { }, status: FeatureFlagStatus.Active, }, + earnMoneyParallaxAnimationEnabled: { + name: 'earnMoneyParallaxAnimationEnabled', + type: FeatureFlagType.Remote, + inProd: false, + productionDefault: false, + status: FeatureFlagStatus.Active, + }, earnMoneyVaultApyControl: { name: 'earnMoneyVaultApyControl',