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"