diff --git a/__mocks__/expo-splash-screen.ts b/__mocks__/expo-splash-screen.ts index da0849f1..4c24f029 100644 --- a/__mocks__/expo-splash-screen.ts +++ b/__mocks__/expo-splash-screen.ts @@ -1,5 +1,3 @@ -export default { - preventAutoHideAsync: jest.fn().mockResolvedValue(undefined), - hide: jest.fn(), - setOptions: jest.fn(), -}; +export const preventAutoHideAsync = jest.fn().mockResolvedValue(undefined); +export const hide = jest.fn(); +export const setOptions = jest.fn(); diff --git a/__mocks__/react-native-mmkv.ts b/__mocks__/react-native-mmkv.ts index 106e59a8..575bcbd6 100644 --- a/__mocks__/react-native-mmkv.ts +++ b/__mocks__/react-native-mmkv.ts @@ -1,6 +1,6 @@ const mmkvMock = (() => { const data = {}; - const MMKV = { + return { getBoolean: (key: string) => data[key], getString: (key: string) => data[key], getNumber: (key: string) => data[key], @@ -8,7 +8,6 @@ const mmkvMock = (() => { getAllKeys: () => Object.keys(data), delete: (key: string) => (data[key] = undefined), }; - return { MMKV }; })(); export const MMKV = jest.fn().mockImplementation(() => mmkvMock); diff --git a/__mocks__/react-native-purchases.ts b/__mocks__/react-native-purchases.ts new file mode 100644 index 00000000..ddd4c7aa --- /dev/null +++ b/__mocks__/react-native-purchases.ts @@ -0,0 +1,42 @@ +export const PURCHASES_ERROR_CODE = { + UNKNOWN_ERROR: 0, + PURCHASE_CANCELLED_ERROR: 1, + STORE_PROBLEM_ERROR: 2, + PURCHASE_NOT_ALLOWED_ERROR: 3, + PURCHASE_INVALID_ERROR: 4, + PRODUCT_NOT_AVAILABLE_FOR_PURCHASE_ERROR: 5, + PRODUCT_ALREADY_PURCHASED_ERROR: 6, + RECEIPT_ALREADY_IN_USE_ERROR: 7, + INVALID_RECEIPT_ERROR: 8, + MISSING_RECEIPT_FILE_ERROR: 9, + NETWORK_ERROR: 10, + INVALID_CREDENTIALS_ERROR: 11, + UNEXPECTED_BACKEND_RESPONSE_ERROR: 12, + INVALID_APP_USER_ID_ERROR: 14, + OPERATION_ALREADY_IN_PROGRESS_ERROR: 15, + UNKNOWN_BACKEND_ERROR: 16, +}; + +export const LOG_LEVEL = { + VERBOSE: 'VERBOSE', + DEBUG: 'DEBUG', + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', +}; + +const Purchases = { + configureWith: jest.fn(), + getOfferings: jest.fn().mockResolvedValue({ all: {}, current: null }), + getCustomerInfo: jest.fn().mockResolvedValue({}), + purchasePackage: jest.fn().mockResolvedValue({}), + restorePurchases: jest.fn().mockResolvedValue({}), + setLogLevel: jest.fn(), + setDebugLogsEnabled: jest.fn(), + getAppUserID: jest.fn().mockResolvedValue('mock-user-id'), + logIn: jest.fn().mockResolvedValue({}), + logOut: jest.fn().mockResolvedValue({}), + setAttributes: jest.fn().mockResolvedValue(undefined), +}; + +export default Purchases; diff --git a/package.json b/package.json index db74bb7b..f690b169 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "expo-device": "~8.0.9", "expo-file-system": "~19.0.17", "expo-font": "~14.0.9", + "expo-haptics": "~15.0.7", "expo-image": "~3.0.9", "expo-linking": "~8.0.8", "expo-localization": "~17.0.7", @@ -98,6 +99,7 @@ "i18next": "25.6.0", "immer": "10.1.3", "intl-pluralrules": "2.0.1", + "lodash.debounce": "4.0.8", "lodash.isequal": "4.5.0", "lodash.isnil": "4.0.0", "lodash.memoize": "4.1.2", @@ -152,6 +154,7 @@ "@types/imagemin": "8.0.0", "@types/imagemin-mozjpeg": "8.0.1", "@types/jest": "29.5.14", + "@types/lodash.debounce": "4.0.9", "@types/lodash.isequal": "4.5.8", "@types/lodash.isnil": "4.0.9", "@types/lodash.memoize": "4.1.9", diff --git a/src/infra/haptics/haptics.ts b/src/infra/haptics/haptics.ts new file mode 100644 index 00000000..33ecfce2 --- /dev/null +++ b/src/infra/haptics/haptics.ts @@ -0,0 +1,61 @@ +import * as Haptics from 'expo-haptics'; + +import { IS_ANDROID } from '$domain/constants'; + +export type HapticFeedbackType = 'light' | 'medium' | 'heavy' | 'selection'; +export type HapticNotificationType = 'success' | 'warning' | 'error'; + +const mapHapticFeedbackTypeToHapticsType: Record< + HapticFeedbackType, + Haptics.ImpactFeedbackStyle +> = { + light: Haptics.ImpactFeedbackStyle.Light, + medium: Haptics.ImpactFeedbackStyle.Medium, + heavy: Haptics.ImpactFeedbackStyle.Heavy, + selection: Haptics.ImpactFeedbackStyle.Light, +}; + +const mapHapticNotificationTypeToAndroidType: Record< + HapticNotificationType, + Haptics.AndroidHaptics +> = { + success: Haptics.AndroidHaptics.Confirm, + warning: Haptics.AndroidHaptics.Reject, + error: Haptics.AndroidHaptics.Reject, +}; +const mapHapticNotificationTypeToHapticsType: Record< + HapticNotificationType, + Haptics.NotificationFeedbackType +> = { + success: Haptics.NotificationFeedbackType.Success, + warning: Haptics.NotificationFeedbackType.Warning, + error: Haptics.NotificationFeedbackType.Error, +}; + +export const triggerHapticFeedback = (type: HapticFeedbackType = 'medium') => { + if (IS_ANDROID) { + void Haptics.performAndroidHapticsAsync( + Haptics.AndroidHaptics.Context_Click, + ); + + return; + } + + if (type === 'selection') void Haptics.selectionAsync(); + + void Haptics.impactAsync(mapHapticFeedbackTypeToHapticsType[type]); +}; + +export const triggerHapticNotification = ( + type: HapticNotificationType = 'success', +) => { + if (IS_ANDROID) { + void Haptics.performAndroidHapticsAsync( + mapHapticNotificationTypeToAndroidType[type], + ); + + return; + } + + void Haptics.notificationAsync(mapHapticNotificationTypeToHapticsType[type]); +}; diff --git a/src/infra/haptics/index.ts b/src/infra/haptics/index.ts new file mode 100644 index 00000000..31f095d4 --- /dev/null +++ b/src/infra/haptics/index.ts @@ -0,0 +1 @@ +export * from './haptics'; diff --git a/src/shared/components/Pressable.tsx b/src/shared/components/Pressable.tsx new file mode 100644 index 00000000..9e3f61c2 --- /dev/null +++ b/src/shared/components/Pressable.tsx @@ -0,0 +1,44 @@ +/* eslint-disable react/jsx-props-no-spreading */ + +import React, { useCallback } from 'react'; +import { + GestureResponderEvent, + Pressable as RNPressable, + PressableProps, +} from 'react-native'; + +import { HapticFeedbackType, triggerHapticFeedback } from '$infra/haptics'; +import { useDebouncedFunction } from '$shared/hooks'; + +type CustomPressableProps = PressableProps & { + hapticFeedback?: boolean; + hapticFeedbackType?: HapticFeedbackType; + children: React.ReactNode; +}; + +export const Pressable = ({ + hapticFeedback = false, + hapticFeedbackType = 'light', + children, + onPress, + ...props +}: CustomPressableProps) => { + const handlePress = useCallback( + (event: GestureResponderEvent) => { + if (hapticFeedback || hapticFeedbackType !== 'light') { + triggerHapticFeedback(hapticFeedbackType); + } + + onPress?.(event); + }, + [hapticFeedback, hapticFeedbackType, onPress], + ); + + const debouncedPress = useDebouncedFunction(handlePress); + + return ( + + {children} + + ); +}; diff --git a/src/shared/components/index.ts b/src/shared/components/index.ts index 3401aada..91301fe9 100644 --- a/src/shared/components/index.ts +++ b/src/shared/components/index.ts @@ -2,6 +2,7 @@ export * from './appUpdateNeeded'; export * from './fullscreenErrorBoundary'; export * from './maintenanceMode'; export * from './navigation'; +export * from './Pressable'; export * from './screen'; export * from './splashscreen'; export * from './storeUpdateAvailableBanner'; diff --git a/src/shared/components/navigation/components/HeaderLeft.tsx b/src/shared/components/navigation/components/HeaderLeft.tsx index ec5b12b8..9ae66441 100644 --- a/src/shared/components/navigation/components/HeaderLeft.tsx +++ b/src/shared/components/navigation/components/HeaderLeft.tsx @@ -1,8 +1,8 @@ import { useRouter } from 'expo-router'; -import { Pressable } from 'react-native'; import { useUnistyles } from 'react-native-unistyles'; import { DEFAULT_ICON_SIZE, HIT_SLOP } from '$domain/constants/styling'; +import { Pressable } from '$shared/components'; import { Icon } from '$shared/icons'; export const HeaderLeft = () => { diff --git a/src/shared/hooks/__tests__/useDebouncedFunction.test.ts b/src/shared/hooks/__tests__/useDebouncedFunction.test.ts new file mode 100644 index 00000000..9d0b6054 --- /dev/null +++ b/src/shared/hooks/__tests__/useDebouncedFunction.test.ts @@ -0,0 +1,98 @@ +import { renderHook } from '$domain/testing'; + +import { useDebouncedFunction } from '../useDebouncedFunction'; + +describe('useDebouncedFunction', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('should return a stable debounced function', () => { + const callback = jest.fn(); + const { result, rerender } = renderHook(() => + useDebouncedFunction(callback, 500, {}), + ); + + const firstResult = result.current; + + rerender({}); + + expect(result.current).toBe(firstResult); + }); + + it('should call the latest version of the callback', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + const { result, rerender } = renderHook( + (props: { cb: () => void }) => useDebouncedFunction(props.cb, 100), + { initialProps: { cb: callback1 } }, + ); + + result.current(); + + jest.advanceTimersByTime(100); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + + rerender({ cb: callback2 }); + + result.current(); + + jest.advanceTimersByTime(100); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should debounce function calls', () => { + const callback = jest.fn(); + const { result } = renderHook(() => + useDebouncedFunction(callback, 500, { leading: false, trailing: true }), + ); + + result.current(); + result.current(); + result.current(); + + expect(callback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(500); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should pass arguments correctly', () => { + const callback = jest.fn(); + const { result } = renderHook(() => useDebouncedFunction(callback, 100)); + + result.current('arg1', 'arg2', 123); + + jest.advanceTimersByTime(100); + + expect(callback).toHaveBeenCalledWith('arg1', 'arg2', 123); + }); + + it('should support leading edge option', () => { + const callback = jest.fn(); + const { result } = renderHook(() => + useDebouncedFunction(callback, 500, { leading: true, trailing: false }), + ); + + result.current(); + result.current(); + result.current(); + + expect(callback).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(500); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/hooks/index.ts b/src/shared/hooks/index.ts index 5778e502..b878486e 100644 --- a/src/shared/hooks/index.ts +++ b/src/shared/hooks/index.ts @@ -1,5 +1,6 @@ export * from './useAppState'; export * from './useCheckNetworkStateOnMount'; +export * from './useDebouncedFunction'; export * from './useGetSessionState'; export * from './useIsNewStoreVersionAvailable'; export * from './useKeyboard'; diff --git a/src/shared/hooks/useDebouncedFunction.ts b/src/shared/hooks/useDebouncedFunction.ts new file mode 100644 index 00000000..15b3ab20 --- /dev/null +++ b/src/shared/hooks/useDebouncedFunction.ts @@ -0,0 +1,26 @@ +import debounce from 'lodash.debounce'; +import { useEffect, useMemo, useRef } from 'react'; + +type DebounceFn = typeof debounce; + +export const useDebouncedFunction: DebounceFn = ( + fn, + wait = 500, + { maxWait, leading = true, trailing = false } = {}, +) => { + const fnRef = useRef(fn); + + useEffect(() => { + fnRef.current = fn; + }, [fn]); + + return useMemo(() => { + const wrappedFn = (...args: Parameters) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return fnRef.current(...args); + }; + + // eslint-disable-next-line react-hooks/refs + return debounce(wrappedFn, wait, { leading, maxWait, trailing }); + }, [leading, maxWait, trailing, wait]); +}; diff --git a/src/shared/uiKit/bottomSheet/components/AnimatedBottomSheetBackdrop.tsx b/src/shared/uiKit/bottomSheet/components/AnimatedBottomSheetBackdrop.tsx index fcef310c..68255b8b 100644 --- a/src/shared/uiKit/bottomSheet/components/AnimatedBottomSheetBackdrop.tsx +++ b/src/shared/uiKit/bottomSheet/components/AnimatedBottomSheetBackdrop.tsx @@ -3,7 +3,6 @@ import { type BottomSheetBackdropProps, } from '@gorhom/bottom-sheet'; import { useMemo } from 'react'; -import { Pressable } from 'react-native'; import Animated, { Extrapolation, interpolate, @@ -11,6 +10,8 @@ import Animated, { } from 'react-native-reanimated'; import { StyleSheet, useUnistyles } from 'react-native-unistyles'; +import { Pressable } from '$shared/components'; + export const AnimatedBottomSheetBackdrop = ({ animatedIndex, style, diff --git a/src/shared/uiKit/button/BaseButton.tsx b/src/shared/uiKit/button/BaseButton.tsx index 7d67b54e..5e7283df 100644 --- a/src/shared/uiKit/button/BaseButton.tsx +++ b/src/shared/uiKit/button/BaseButton.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-hooks/immutability */ import type React from 'react'; -import { GestureResponderEvent, Pressable, View } from 'react-native'; +import { GestureResponderEvent, View } from 'react-native'; import Animated, { cancelAnimation, useAnimatedStyle, @@ -13,6 +13,7 @@ import type { UnistylesThemes } from 'react-native-unistyles'; import { useUnistyles } from 'react-native-unistyles'; import { HIT_SLOP } from '$domain/constants/styling'; +import { Pressable } from '$shared/components/Pressable'; import { buttonVariants, type ButtonVariant } from './buttonVariants'; import type { ButtonProps } from './types/buttonTypes'; diff --git a/yarn.lock b/yarn.lock index cb07273e..5e3f6952 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7959,6 +7959,15 @@ __metadata: languageName: node linkType: hard +"@types/lodash.debounce@npm:4.0.9": + version: 4.0.9 + resolution: "@types/lodash.debounce@npm:4.0.9" + dependencies: + "@types/lodash": "npm:*" + checksum: 10c0/9fbb24e5e52616faf60ba5c82d8c6517f4b86fc6e9ab353b4c56c0760f63d9bf53af3f2d8f6c37efa48090359fb96dba1087d497758511f6c40677002191d042 + languageName: node + linkType: hard + "@types/lodash.isequal@npm:4.5.8": version: 4.5.8 resolution: "@types/lodash.isequal@npm:4.5.8" @@ -12861,6 +12870,15 @@ __metadata: languageName: node linkType: hard +"expo-haptics@npm:~15.0.7": + version: 15.0.7 + resolution: "expo-haptics@npm:15.0.7" + peerDependencies: + expo: "*" + checksum: 10c0/72daaf272b6ab3f650d76d901b3f744d497c9120b77d274560cccff6da985c867256212108c2057843996fad5ea8905614aae4747a217d6502f0c0156eca6d5d + languageName: node + linkType: hard + "expo-image@npm:~3.0.9": version: 3.0.9 resolution: "expo-image@npm:3.0.9" @@ -17064,7 +17082,7 @@ __metadata: languageName: node linkType: hard -"lodash.debounce@npm:^4.0.8": +"lodash.debounce@npm:4.0.8, lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" checksum: 10c0/762998a63e095412b6099b8290903e0a8ddcb353ac6e2e0f2d7e7d03abd4275fe3c689d88960eb90b0dde4f177554d51a690f22a343932ecbc50a5d111849987 @@ -21111,6 +21129,7 @@ __metadata: "@types/imagemin": "npm:8.0.0" "@types/imagemin-mozjpeg": "npm:8.0.1" "@types/jest": "npm:29.5.14" + "@types/lodash.debounce": "npm:4.0.9" "@types/lodash.isequal": "npm:4.5.8" "@types/lodash.isnil": "npm:4.0.9" "@types/lodash.memoize": "npm:4.1.9" @@ -21134,6 +21153,7 @@ __metadata: expo-device: "npm:~8.0.9" expo-file-system: "npm:~19.0.17" expo-font: "npm:~14.0.9" + expo-haptics: "npm:~15.0.7" expo-image: "npm:~3.0.9" expo-linking: "npm:~8.0.8" expo-localization: "npm:~17.0.7" @@ -21157,6 +21177,7 @@ __metadata: jest: "npm:29.7.0" jest-expo: "npm:~54.0.12" lefthook: "npm:1.13.6" + lodash.debounce: "npm:4.0.8" lodash.isequal: "npm:4.5.0" lodash.isnil: "npm:4.0.0" lodash.memoize: "npm:4.1.2"