Refactor haptics usage to shared utility (#1812)

This commit is contained in:
Leendert de Borst
2026-03-05 16:13:58 +01:00
committed by Leendert de Borst
parent ec4aba9967
commit ae8c995d16
12 changed files with 88 additions and 75 deletions

View File

@@ -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<void> => {
// 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);

View File

@@ -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({

View File

@@ -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]);

View File

@@ -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]);

View File

@@ -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<void> => {
// 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 (

View File

@@ -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<void> => {
// 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();

View File

@@ -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);

View File

@@ -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<IFolderModalProps> = ({
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) {

View File

@@ -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<AdvancedPasswordFieldRef, Adva
setShowPasswordState(true);
// Haptic feedback for password generation
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
HapticsUtility.impact();
}
}
}, [currentSettings, generatePassword, onChangeText, setShowPasswordState]);

View File

@@ -8,6 +8,7 @@ import DraggableFlatList, {
import type { FieldType } from '@/utils/dist/core/models/vault';
import { FieldTypes } from '@/utils/dist/core/models/vault';
import { HapticsUtility } from '@/utils/HapticsUtility';
import { useColors } from '@/hooks/useColorScheme';
@@ -162,7 +163,7 @@ export const DraggableCustomFieldsList: React.FC<DraggableCustomFieldsListProps>
* Handle drag begin
*/
const handleDragBegin = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
HapticsUtility.impact(Haptics.ImpactFeedbackStyle.Medium);
}, []);
/**

View File

@@ -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<FormInputCopyToClipboardProps> = ({
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) {

View File

@@ -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();
}
}