Add haptic feedback to mobile app primary actions for improved UX (#1802)

This commit is contained in:
Leendert de Borst
2026-03-01 20:31:06 +01:00
committed by Leendert de Borst
parent 608da07bc4
commit 381fe65546
7 changed files with 88 additions and 0 deletions

View File

@@ -446,6 +446,9 @@ class PinUnlockActivity : AppCompatActivity() {
private suspend fun handlePinResult(result: PinResult) {
when (result) {
is PinResult.Success -> {
// Trigger success haptic feedback
triggerSuccessFeedback()
// Success - return result
val resultIntent = Intent()
result.encryptionKey?.let {
@@ -532,6 +535,18 @@ class PinUnlockActivity : AppCompatActivity() {
}
}
private fun triggerSuccessFeedback() {
// Trigger haptic feedback for success
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// Use a lighter, shorter vibration for success (50ms)
vibrator?.vibrate(VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE))
} else {
@Suppress("DEPRECATION")
vibrator?.vibrate(50)
}
}
private fun triggerErrorFeedback() {
// Trigger haptic feedback for error
val vibrator = getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator

View File

@@ -914,6 +914,11 @@ export default function AddEditItemScreen(): React.ReactNode {
setIsSaving(false);
setIsSaveDisabled(false);
// Haptic feedback for successful save
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
// Navigate immediately - sync continues in background
if (itemUrl && !isEditMode) {
router.replace('/items/autofill-item-created');
@@ -976,6 +981,11 @@ 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);
}
setTimeout(() => {
Toast.show({
type: 'success',

View File

@@ -1,3 +1,4 @@
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import { useState, useEffect, useCallback } from 'react';
@@ -122,6 +123,11 @@ export default function UnlockScreen() : React.ReactNode {
return;
}
// Haptic feedback for successful unlock
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
router.replace('/reinitialize');
} catch (err) {
if (err instanceof VaultVersionIncompatibleError) {
@@ -132,6 +138,11 @@ export default function UnlockScreen() : React.ReactNode {
console.error('Unlock error:', err);
const errorCode = getAppErrorCode(err);
// Haptic feedback for authentication error
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
if (!errorCode || errorCode === AppErrorCode.VAULT_DECRYPT_FAILED) {
setError(t('auth.errors.incorrectPassword'));
} else {
@@ -192,6 +203,11 @@ export default function UnlockScreen() : React.ReactNode {
return;
}
// Haptic feedback for successful unlock
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
/*
* Navigate to reinitialize which will sync vault with server
* and then navigate to the appropriate destination.
@@ -207,6 +223,11 @@ export default function UnlockScreen() : React.ReactNode {
// Try to extract error code from the error
const errorCode = getAppErrorCode(err);
// Haptic feedback for authentication error
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
/*
* During unlock, VAULT_DECRYPT_FAILED indicates wrong password.
* This is thrown when decryption fails due to incorrect encryption key.
@@ -240,6 +261,12 @@ export default function UnlockScreen() : React.ReactNode {
router.replace('/upgrade');
return true;
}
// Haptic feedback for successful unlock
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
router.replace('/reinitialize');
return true;
}
@@ -272,6 +299,12 @@ export default function UnlockScreen() : React.ReactNode {
router.replace('/upgrade');
return;
}
// Haptic feedback for successful unlock
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
router.replace('/reinitialize');
} catch (err) {
console.error('Biometric retry error:', err);
@@ -287,6 +320,11 @@ export default function UnlockScreen() : React.ReactNode {
if (pinAvailable) {
await performPinUnlock();
} else if (errorCode) {
// Haptic feedback for authentication error
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
}
// Show the error with code if no PIN fallback
const translationKey = getErrorTranslationKey(errorCode);
setError(formatErrorWithCode(t(translationKey), errorCode));

View File

@@ -1,3 +1,4 @@
import * as Haptics from 'expo-haptics';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
@@ -7,6 +8,7 @@ import {
TouchableOpacity,
View,
ActivityIndicator,
Platform,
} from 'react-native';
import { useColors } from '@/hooks/useColorScheme';
@@ -58,6 +60,12 @@ export const FolderModal: React.FC<IFolderModalProps> = ({
try {
await onSave(trimmedName);
// Haptic feedback for successful folder creation/edit
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
onClose();
} catch (err) {
setError(t('common.errors.unknownErrorTryAgain'));

View File

@@ -1,5 +1,6 @@
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';
@@ -142,6 +143,11 @@ const AdvancedPasswordFieldComponent = forwardRef<AdvancedPasswordFieldRef, Adva
if (password) {
onChangeText(password);
setShowPasswordState(true);
// Haptic feedback for password generation
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
}
}
}, [currentSettings, generatePassword, onChangeText, setShowPasswordState]);

View File

@@ -1,4 +1,5 @@
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';
@@ -105,6 +106,11 @@ const FormInputCopyToClipboard: React.FC<FormInputCopyToClipboardProps> = ({
// Use centralized clipboard utility
await copyToClipboardWithExpiration(value, timeoutSeconds);
// Haptic feedback for successful copy
if (Platform.OS === 'ios' || Platform.OS === 'android') {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
// Handle animation state
if (timeoutSeconds > 0) {
// Clear any existing active field and set this one as active

View File

@@ -282,6 +282,11 @@ public class PinUnlockViewModel: ObservableObject {
// Call the injected unlock handler with the PIN
// This will perform Argon2 key derivation which may take 500ms-1s
try await unlockHandler(pin)
// Success - trigger success haptic feedback
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.success)
// Success - the handler will navigate away or complete the flow
// Keep loading state active since we're navigating
} catch let pinError as PinUnlockError {