Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@
"ethers": "^5.8.0",
"expo": "~53.0.20",
"expo-application": "~6.1.5",
"expo-image": "~2.3.0",
"expo-clipboard": "~7.1.5",
"expo-dev-client": "~5.2.4",
"expo-haptics": "~14.1.4",
"expo-image": "~2.3.0",
"expo-notifications": "^0.31.5",
"expo-status-bar": "~2.2.3",
"graphql": "^16.13.2",
Expand Down
22,137 changes: 22,137 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

47 changes: 45 additions & 2 deletions src/components/common/Button.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import {
TouchableOpacity,
Text,
Expand All @@ -9,6 +9,7 @@ import {
} from 'react-native';
import { spacing, typography, borderRadius } from '../../utils/constants';
import { useThemeColors } from '../../hooks/useThemeColors';
import { useHaptics } from '../../hooks/useHaptics';

export interface ButtonProps {
title: string;
Expand All @@ -23,6 +24,16 @@ export interface ButtonProps {
accessibilityHint?: string;
accessibilitySelected?: boolean;
testID?: string;
/**
* Controls which haptic pattern fires when the button is pressed.
* - 'light' (default) — standard tap feedback
* - 'medium' — confirmations / selections
* - 'heavy' — destructive actions
* - 'success' — use after a successful async operation completes
* - 'error' — use when the action is known to have failed
* - 'none' — opt out of haptics entirely
*/
hapticVariant?: 'light' | 'medium' | 'heavy' | 'success' | 'error' | 'none';
}

export const Button: React.FC<ButtonProps> = ({
Expand All @@ -38,9 +49,41 @@ export const Button: React.FC<ButtonProps> = ({
accessibilityHint,
accessibilitySelected = false,
testID,
hapticVariant = 'light',
}) => {
const colors = useThemeColors();
const styles = React.useMemo(() => createStyles(colors), [colors]);
const { triggerLight, triggerMedium, triggerHeavy, triggerSuccess, triggerError } = useHaptics();

const handlePress = useCallback(() => {
switch (hapticVariant) {
case 'medium':
triggerMedium();
break;
case 'heavy':
triggerHeavy();
break;
case 'success':
triggerSuccess();
break;
case 'error':
triggerError();
break;
case 'none':
break;
default:
triggerLight();
}
onPress();
}, [
hapticVariant,
onPress,
triggerLight,
triggerMedium,
triggerHeavy,
triggerSuccess,
triggerError,
]);

const buttonStyle = [
styles.button,
Expand All @@ -61,7 +104,7 @@ export const Button: React.FC<ButtonProps> = ({
return (
<TouchableOpacity
style={buttonStyle}
onPress={onPress}
onPress={handlePress}
disabled={disabled || loading}
activeOpacity={0.8}
accessibilityRole="button"
Expand Down
11 changes: 9 additions & 2 deletions src/components/common/FloatingActionButton.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useCallback } from 'react';
import { TouchableOpacity, Text, StyleSheet, ViewStyle } from 'react-native';
import { spacing, typography, borderRadius, shadows } from '../../utils/constants';
import { useThemeColors } from '../../hooks/useThemeColors';
import { useHaptics } from '../../hooks/useHaptics';

export interface FloatingActionButtonProps {
onPress: () => void;
Expand All @@ -26,12 +27,18 @@ export const FloatingActionButton: React.FC<FloatingActionButtonProps> = ({
}) => {
const colors = useThemeColors();
const styles = React.useMemo(() => createStyles(colors), [colors]);
const { triggerMedium } = useHaptics();
const buttonStyle = [styles.button, styles[size], style];

const handlePress = useCallback(() => {
triggerMedium();
onPress();
}, [onPress, triggerMedium]);

return (
<TouchableOpacity
style={buttonStyle}
onPress={onPress}
onPress={handlePress}
activeOpacity={0.8}
testID={testID}
accessibilityRole="button"
Expand Down
91 changes: 91 additions & 0 deletions src/hooks/useHaptics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* useHaptics
*
* Centralised haptic feedback utility for SubTrackr.
*
* Feedback mapping
* ─────────────────────────────────────────────────────────────────
* triggerLight() → ImpactFeedbackStyle.Light (standard taps / button presses)
* triggerMedium() → ImpactFeedbackStyle.Medium (confirmations / selections)
* triggerHeavy() → ImpactFeedbackStyle.Heavy (destructive / high-impact actions)
* triggerSuccess() → NotificationFeedbackType.Success (save / submit succeeded)
* triggerError() → NotificationFeedbackType.Error (validation / network failure)
* triggerWarning() → NotificationFeedbackType.Warning (caution prompts)
*
* All calls are fire-and-forget (void) so they never block the UI thread.
* expo-haptics silently no-ops on platforms / devices that don't support
* haptics (Android without vibrator, web, simulator), so no extra guards
* are needed.
*/

import * as Haptics from 'expo-haptics';
import { useCallback } from 'react';

// ─── standalone helpers (usable outside React components) ────────────────────

/** Standard button press / list-item tap. */
export const triggerLightHaptic = (): void => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};

/** Confirmations, selections, toggles. */
export const triggerMediumHaptic = (): void => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
};

/** Destructive actions (delete, force-cancel). */
export const triggerHeavyHaptic = (): void => {
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
};

/** Successful save / submit / completion. */
export const triggerSuccessHaptic = (): void => {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
};

/** Validation error, network failure, rejected action. */
export const triggerErrorHaptic = (): void => {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
};

/** Non-blocking warning / caution prompt. */
export const triggerWarningHaptic = (): void => {
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
};

// ─── React hook (memoised callbacks, safe to use in components) ──────────────

export interface UseHapticsReturn {
/** Standard button press / list-item tap. */
triggerLight: () => void;
/** Confirmations, selections, toggles. */
triggerMedium: () => void;
/** Destructive actions (delete, force-cancel). */
triggerHeavy: () => void;
/** Successful save / submit / completion. */
triggerSuccess: () => void;
/** Validation error, network failure, rejected action. */
triggerError: () => void;
/** Non-blocking warning / caution prompt. */
triggerWarning: () => void;
}

export function useHaptics(): UseHapticsReturn {
const triggerLight = useCallback(triggerLightHaptic, []);
const triggerMedium = useCallback(triggerMediumHaptic, []);
const triggerHeavy = useCallback(triggerHeavyHaptic, []);
const triggerSuccess = useCallback(triggerSuccessHaptic, []);
const triggerError = useCallback(triggerErrorHaptic, []);
const triggerWarning = useCallback(triggerWarningHaptic, []);

return {
triggerLight,
triggerMedium,
triggerHeavy,
triggerSuccess,
triggerError,
triggerWarning,
};
}

export default useHaptics;
Loading