From ae8c995d16fc69bfaf5f53d9304531e0484c9fd2 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 5 Mar 2026 16:13:58 +0100 Subject: [PATCH] Refactor haptics usage to shared utility (#1812) --- apps/mobile-app/app/(tabs)/emails/index.tsx | 10 ++-- apps/mobile-app/app/(tabs)/items/add-edit.tsx | 14 ++---- .../app/(tabs)/items/folder/[id].tsx | 8 ++-- apps/mobile-app/app/(tabs)/items/index.tsx | 8 ++-- .../settings/security/active-sessions.tsx | 13 ++--- .../(tabs)/settings/security/auth-logs.tsx | 9 ++-- apps/mobile-app/app/unlock.tsx | 28 +++-------- .../components/folders/FolderModal.tsx | 7 ++- .../components/form/AdvancedPasswordField.tsx | 8 ++-- .../form/DraggableCustomFieldsList.tsx | 3 +- .../form/FormInputCopyToClipboard.tsx | 8 ++-- apps/mobile-app/utils/HapticsUtility.ts | 47 +++++++++++++++++++ 12 files changed, 88 insertions(+), 75 deletions(-) create mode 100644 apps/mobile-app/utils/HapticsUtility.ts diff --git a/apps/mobile-app/app/(tabs)/emails/index.tsx b/apps/mobile-app/app/(tabs)/emails/index.tsx index 1dd9190da..63f98f2ab 100644 --- a/apps/mobile-app/app/(tabs)/emails/index.tsx +++ b/apps/mobile-app/app/(tabs)/emails/index.tsx @@ -1,14 +1,14 @@ -import * as Haptics from 'expo-haptics'; import { useNavigation } from 'expo-router'; import React, { useEffect, useState, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View, ScrollView, RefreshControl, Animated , Platform } from 'react-native'; +import { StyleSheet, Platform, View, ScrollView, RefreshControl, Animated } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; import type { MailboxBulkRequest, MailboxBulkResponse, MailboxEmail } from '@/utils/dist/core/models/webapi'; import EncryptionUtility from '@/utils/EncryptionUtility'; import emitter from '@/utils/EventEmitter'; +import { HapticsUtility } from '@/utils/HapticsUtility'; import { useColors } from '@/hooks/useColorScheme'; import { useMinDurationLoading } from '@/hooks/useMinDurationLoading'; @@ -145,11 +145,7 @@ export default function EmailsScreen() : React.ReactNode { */ const onRefresh = useCallback(async () : Promise => { // Trigger haptic feedback when pull-to-refresh is activated - if (Platform.OS === 'ios') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } else if (Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); setIsLoading(true); setIsRefreshing(true); diff --git a/apps/mobile-app/app/(tabs)/items/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx index 28f171453..30edb2d5c 100644 --- a/apps/mobile-app/app/(tabs)/items/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -9,6 +9,8 @@ import { useTranslation } from 'react-i18next'; import { StyleSheet, View, Keyboard, Platform, ScrollView, KeyboardAvoidingView, TouchableOpacity } from 'react-native'; import Toast from 'react-native-toast-message'; +import { HapticsUtility } from '@/utils/HapticsUtility'; + import type { Folder } from '@/utils/db/repositories/FolderRepository'; import { CreateIdentityGenerator, CreateUsernameEmailGenerator, UsernameEmailGenerator, Gender, Identity, IdentityHelperUtils, convertAgeRangeToBirthdateOptions } from '@/utils/dist/core/identity-generator'; import type { Attachment, Item, ItemField, TotpCode, ItemType, FieldType, PasswordSettings } from '@/utils/dist/core/models/vault'; @@ -671,9 +673,7 @@ export default function AddEditItemScreen(): React.ReactNode { Fields: [] }); - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); }, [item, isEditMode]); /** @@ -915,9 +915,7 @@ export default function AddEditItemScreen(): React.ReactNode { setIsSaveDisabled(false); // Haptic feedback for successful save - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Success); // Navigate immediately - sync continues in background if (itemUrl && !isEditMode) { @@ -982,9 +980,7 @@ export default function AddEditItemScreen(): React.ReactNode { emitter.emit('credentialChanged', id); // Haptic feedback for delete action (warning type for destructive action) - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Warning); setTimeout(() => { Toast.show({ diff --git a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx index 5fb3657fd..fb8e0e08e 100644 --- a/apps/mobile-app/app/(tabs)/items/folder/[id].tsx +++ b/apps/mobile-app/app/(tabs)/items/folder/[id].tsx @@ -1,6 +1,5 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useNavigation } from '@react-navigation/native'; -import * as Haptics from 'expo-haptics'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,6 +12,7 @@ import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsReposi import type { Item, ItemType } from '@/utils/dist/core/models/vault'; import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; +import { HapticsUtility } from '@/utils/HapticsUtility'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; import { useColors } from '@/hooks/useColorScheme'; @@ -227,9 +227,7 @@ export default function FolderViewScreen(): React.ReactNode { * Handle pull-to-refresh. */ const onRefresh = useCallback(async () => { - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); setRefreshing(true); setIsLoadingItems(true); @@ -404,7 +402,7 @@ export default function FolderViewScreen(): React.ReactNode { const handleAddItem = useCallback(() => { navigate(() => { router.push(`/(tabs)/items/add-edit?folderId=${folderId}` as '/(tabs)/items/add-edit'); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + HapticsUtility.impact(); }); }, [folderId, router, navigate]); diff --git a/apps/mobile-app/app/(tabs)/items/index.tsx b/apps/mobile-app/app/(tabs)/items/index.tsx index dc724fe5f..78235b610 100644 --- a/apps/mobile-app/app/(tabs)/items/index.tsx +++ b/apps/mobile-app/app/(tabs)/items/index.tsx @@ -1,6 +1,5 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { useNavigation } from '@react-navigation/native'; -import * as Haptics from 'expo-haptics'; import { useRouter, useLocalSearchParams } from 'expo-router'; import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,6 +12,7 @@ import type { CredentialSortOrder } from '@/utils/db/repositories/SettingsReposi import type { Item, ItemType } from '@/utils/dist/core/models/vault'; import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault'; import emitter from '@/utils/EventEmitter'; +import { HapticsUtility } from '@/utils/HapticsUtility'; import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError'; import { useColors } from '@/hooks/useColorScheme'; @@ -367,9 +367,7 @@ export default function ItemsScreen(): React.ReactNode { * Handle pull-to-refresh. */ const onRefresh = useCallback(async () => { - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); setRefreshing(true); setIsLoadingItems(true); @@ -549,7 +547,7 @@ export default function ItemsScreen(): React.ReactNode { } else { router.push('/(tabs)/items/add-edit'); } - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + HapticsUtility.impact(); }); }, [router, searchQuery, navigate]); diff --git a/apps/mobile-app/app/(tabs)/settings/security/active-sessions.tsx b/apps/mobile-app/app/(tabs)/settings/security/active-sessions.tsx index 429fc88ef..4a68c7e36 100644 --- a/apps/mobile-app/app/(tabs)/settings/security/active-sessions.tsx +++ b/apps/mobile-app/app/(tabs)/settings/security/active-sessions.tsx @@ -1,9 +1,10 @@ -import * as Haptics from 'expo-haptics'; import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View, TouchableOpacity, RefreshControl, Platform } from 'react-native'; +import { StyleSheet, View, TouchableOpacity, RefreshControl } from 'react-native'; import Toast from 'react-native-toast-message'; +import { HapticsUtility } from '@/utils/HapticsUtility'; + import type { RefreshToken } from '@/utils/dist/core/models/webapi'; import { useColors } from '@/hooks/useColorScheme'; @@ -136,9 +137,7 @@ export default function ActiveSessionsScreen() : React.ReactNode { */ const onRefresh = async () : Promise => { // Trigger haptic feedback when pull-to-refresh is activated - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); setIsRefreshing(true); await loadSessions(); @@ -175,9 +174,7 @@ export default function ActiveSessionsScreen() : React.ReactNode { }); // Trigger haptic feedback when toggling - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); }; return ( diff --git a/apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx b/apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx index 87cee8451..892de1a35 100644 --- a/apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx +++ b/apps/mobile-app/app/(tabs)/settings/security/auth-logs.tsx @@ -1,9 +1,10 @@ -import * as Haptics from 'expo-haptics'; import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { StyleSheet, View, RefreshControl, Platform } from 'react-native'; +import { StyleSheet, View, RefreshControl } from 'react-native'; import Toast from 'react-native-toast-message'; +import { HapticsUtility } from '@/utils/HapticsUtility'; + import type { AuthLogModel } from '@/utils/dist/core/models/webapi'; import { AuthEventType } from '@/utils/dist/core/models/webapi'; @@ -109,9 +110,7 @@ export default function AuthLogsScreen() : React.ReactNode { */ const onRefresh = async () : Promise => { // Trigger haptic feedback when pull-to-refresh is activated - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); setIsRefreshing(true); await loadLogs(); diff --git a/apps/mobile-app/app/unlock.tsx b/apps/mobile-app/app/unlock.tsx index 1bc79d066..9c7f6fbea 100644 --- a/apps/mobile-app/app/unlock.tsx +++ b/apps/mobile-app/app/unlock.tsx @@ -124,9 +124,7 @@ export default function UnlockScreen() : React.ReactNode { } // Haptic feedback for successful unlock - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Success); router.replace('/reinitialize'); } catch (err) { @@ -139,9 +137,7 @@ export default function UnlockScreen() : React.ReactNode { const errorCode = getAppErrorCode(err); // Haptic feedback for authentication error - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Error); if (!errorCode || errorCode === AppErrorCode.VAULT_DECRYPT_FAILED) { setError(t('auth.errors.incorrectPassword')); @@ -204,9 +200,7 @@ export default function UnlockScreen() : React.ReactNode { } // Haptic feedback for successful unlock - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Success); /* * Navigate to reinitialize which will sync vault with server @@ -224,9 +218,7 @@ export default function UnlockScreen() : React.ReactNode { const errorCode = getAppErrorCode(err); // Haptic feedback for authentication error - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Error); /* * During unlock, VAULT_DECRYPT_FAILED indicates wrong password. @@ -263,9 +255,7 @@ export default function UnlockScreen() : React.ReactNode { } // Haptic feedback for successful unlock - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Success); router.replace('/reinitialize'); return true; @@ -301,9 +291,7 @@ export default function UnlockScreen() : React.ReactNode { } // Haptic feedback for successful unlock - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Success); router.replace('/reinitialize'); } catch (err) { @@ -321,9 +309,7 @@ export default function UnlockScreen() : React.ReactNode { await performPinUnlock(); } else if (errorCode) { // Haptic feedback for authentication error - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Error); // Show the error with code if no PIN fallback const translationKey = getErrorTranslationKey(errorCode); diff --git a/apps/mobile-app/components/folders/FolderModal.tsx b/apps/mobile-app/components/folders/FolderModal.tsx index 0d081004d..aa808062a 100644 --- a/apps/mobile-app/components/folders/FolderModal.tsx +++ b/apps/mobile-app/components/folders/FolderModal.tsx @@ -8,9 +8,10 @@ import { TouchableOpacity, View, ActivityIndicator, - Platform, } from 'react-native'; +import { HapticsUtility } from '@/utils/HapticsUtility'; + import { useColors } from '@/hooks/useColorScheme'; import { ModalWrapper } from '@/components/common/ModalWrapper'; @@ -62,9 +63,7 @@ export const FolderModal: React.FC = ({ await onSave(trimmedName); // Haptic feedback for successful folder creation/edit - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); - } + HapticsUtility.notification(Haptics.NotificationFeedbackType.Success); onClose(); } catch (err) { diff --git a/apps/mobile-app/components/form/AdvancedPasswordField.tsx b/apps/mobile-app/components/form/AdvancedPasswordField.tsx index 4eb88511a..63d63ed4c 100644 --- a/apps/mobile-app/components/form/AdvancedPasswordField.tsx +++ b/apps/mobile-app/components/form/AdvancedPasswordField.tsx @@ -1,12 +1,12 @@ import { MaterialIcons } from '@expo/vector-icons'; import Slider from '@react-native-community/slider'; -import * as Haptics from 'expo-haptics'; import React, { forwardRef, useImperativeHandle, useMemo, useRef, useState, useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { View, TextInput, TextInputProps, StyleSheet, TouchableOpacity, Platform, ScrollView, Switch } from 'react-native'; +import { View, TextInput, TextInputProps, StyleSheet, Platform, TouchableOpacity, ScrollView, Switch } from 'react-native'; import type { PasswordSettings } from '@/utils/dist/core/models/vault'; import { CreatePasswordGenerator } from '@/utils/dist/core/password-generator'; +import { HapticsUtility } from '@/utils/HapticsUtility'; import { sliderToLength, lengthToSlider, SLIDER_MIN, SLIDER_MAX } from '@/utils/passwordLengthSlider'; import { useColors } from '@/hooks/useColorScheme'; @@ -145,9 +145,7 @@ const AdvancedPasswordFieldComponent = forwardRef * Handle drag begin */ const handleDragBegin = useCallback(() => { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + HapticsUtility.impact(Haptics.ImpactFeedbackStyle.Medium); }, []); /** diff --git a/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx b/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx index f6d7d5567..1116d3733 100644 --- a/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx +++ b/apps/mobile-app/components/form/FormInputCopyToClipboard.tsx @@ -1,11 +1,11 @@ import { MaterialIcons } from '@expo/vector-icons'; -import * as Haptics from 'expo-haptics'; import React, { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { View, Text, TouchableOpacity, StyleSheet, Platform, Animated, Easing } from 'react-native'; +import { View, Text, Platform, TouchableOpacity, StyleSheet, Animated, Easing } from 'react-native'; import Toast from 'react-native-toast-message'; import { copyToClipboardWithExpiration } from '@/utils/ClipboardUtility'; +import { HapticsUtility } from '@/utils/HapticsUtility'; import { useColors } from '@/hooks/useColorScheme'; @@ -107,9 +107,7 @@ const FormInputCopyToClipboard: React.FC = ({ await copyToClipboardWithExpiration(value, timeoutSeconds); // Haptic feedback for successful copy - if (Platform.OS === 'ios' || Platform.OS === 'android') { - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - } + HapticsUtility.impact(); // Handle animation state if (timeoutSeconds > 0) { diff --git a/apps/mobile-app/utils/HapticsUtility.ts b/apps/mobile-app/utils/HapticsUtility.ts new file mode 100644 index 000000000..128add6d4 --- /dev/null +++ b/apps/mobile-app/utils/HapticsUtility.ts @@ -0,0 +1,47 @@ +import * as Haptics from 'expo-haptics'; +import { Platform } from 'react-native'; + +/** + * Utility class for managing haptic feedback across the app. + * Provides a centralized way to trigger haptic feedback with platform checks. + */ +export class HapticsUtility { + /** + * Checks if the current platform supports haptics. + * @returns true if platform is iOS or Android, false otherwise + */ + private static isHapticsAvailable(): boolean { + return Platform.OS === 'ios' || Platform.OS === 'android'; + } + + /** + * Triggers impact haptic feedback (for button presses, toggles, etc.) + * Automatically checks if the platform supports haptics (iOS/Android). + * + * @param style - The style of impact feedback (Light, Medium, Heavy, Rigid, Soft) + */ + static impact(style: Haptics.ImpactFeedbackStyle = Haptics.ImpactFeedbackStyle.Light): void { + if (!this.isHapticsAvailable()) return; + Haptics.impactAsync(style); + } + + /** + * Triggers notification haptic feedback (for success, error, warning states). + * Automatically checks if the platform supports haptics (iOS/Android). + * + * @param type - The type of notification feedback (Success, Warning, Error) + */ + static notification(type: Haptics.NotificationFeedbackType): void { + if (!this.isHapticsAvailable()) return; + Haptics.notificationAsync(type); + } + + /** + * Triggers selection haptic feedback (for picker scrolls, slider movements, etc.) + * Automatically checks if the platform supports haptics (iOS/Android). + */ + static selection(): void { + if (!this.isHapticsAvailable()) return; + Haptics.selectionAsync(); + } +}