diff --git a/App.tsx b/App.tsx index bebf155..1157eec 100644 --- a/App.tsx +++ b/App.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { View } from 'react-native'; import { StatusBar } from 'expo-status-bar'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AppNavigator } from './src/navigation/AppNavigator'; import { useNotifications } from './src/hooks/useNotifications'; import { useTransactionQueue } from './src/hooks/useTransactionQueue'; @@ -82,13 +83,15 @@ function NotificationBootstrap() { export default function App() { return ( - - - - - - - - + + + + + + + + + + ); } diff --git a/src/components/common/SwipeableCard.tsx b/src/components/common/SwipeableCard.tsx new file mode 100644 index 0000000..37eb951 --- /dev/null +++ b/src/components/common/SwipeableCard.tsx @@ -0,0 +1,221 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { + Animated, + PanResponder, + PanResponderGestureState, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; + +import { borderRadius, colors, shadows, spacing, typography } from '../../utils/constants'; +import { + buildGestureDebugLabel, + GestureDirection, + resolveGesturePriority, + triggerGestureFeedback, + validateHorizontalSwipe, +} from '../../services/gestureService'; + +interface SwipeableCardProps { + children: React.ReactNode; + onPress: () => void; + onLongPress?: () => void; + onSwipeLeft?: () => void; + onSwipeRight?: () => void; + accessibilityLabel?: string; + debugEnabled?: boolean; +} + +const MAX_SWIPE_TRANSLATION = 96; + +function clampTranslate(value: number): number { + return Math.max(Math.min(value, MAX_SWIPE_TRANSLATION), -MAX_SWIPE_TRANSLATION); +} + +export const SwipeableCard: React.FC = ({ + children, + onPress, + onLongPress, + onSwipeLeft, + onSwipeRight, + accessibilityLabel, + debugEnabled = false, +}) => { + const translateX = useRef(new Animated.Value(0)).current; + const draggingRef = useRef(false); + const longPressTriggeredRef = useRef(false); + const [debugLabel, setDebugLabel] = useState('gesture=tap direction=none'); + + const resetPosition = () => { + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + bounciness: 8, + speed: 16, + }).start(); + }; + + const completeSwipe = (direction: GestureDirection, action?: () => void) => { + Animated.sequence([ + Animated.timing(translateX, { + toValue: direction === 'right' ? 72 : -72, + duration: 120, + useNativeDriver: true, + }), + Animated.spring(translateX, { + toValue: 0, + useNativeDriver: true, + bounciness: 6, + speed: 18, + }), + ]).start(); + + if (action) { + triggerGestureFeedback('success'); + action(); + } + }; + + const handleRelease = (gestureState: PanResponderGestureState) => { + const result = validateHorizontalSwipe(gestureState); + const priority = resolveGesturePriority(result, longPressTriggeredRef.current); + + if (debugEnabled) { + setDebugLabel(buildGestureDebugLabel({ ...result, priority }, gestureState)); + } + + draggingRef.current = false; + longPressTriggeredRef.current = false; + + if (priority !== 'swipe') { + resetPosition(); + return; + } + + if (result.direction === 'right') { + completeSwipe('right', onSwipeRight); + return; + } + + if (result.direction === 'left') { + completeSwipe('left', onSwipeLeft); + return; + } + + resetPosition(); + }; + + const panResponder = useMemo( + () => + PanResponder.create({ + onMoveShouldSetPanResponder: (_, gestureState) => + Math.abs(gestureState.dx) > 8 && Math.abs(gestureState.dx) > Math.abs(gestureState.dy), + onPanResponderGrant: () => { + draggingRef.current = false; + }, + onPanResponderMove: (_, gestureState) => { + draggingRef.current = true; + translateX.setValue(clampTranslate(gestureState.dx)); + if (debugEnabled) { + const result = validateHorizontalSwipe(gestureState); + setDebugLabel(buildGestureDebugLabel(result, gestureState)); + } + }, + onPanResponderTerminationRequest: () => true, + onPanResponderRelease: (_, gestureState) => handleRelease(gestureState), + onPanResponderTerminate: (_, gestureState) => handleRelease(gestureState), + }), + [debugEnabled, onSwipeLeft, onSwipeRight, translateX] + ); + + return ( + + + Quick toggle + Open + + + { + if (draggingRef.current) return; + longPressTriggeredRef.current = true; + triggerGestureFeedback('long-press'); + onLongPress?.(); + setTimeout(() => { + longPressTriggeredRef.current = false; + }, 700); + }} + onPress={() => { + if (draggingRef.current) { + resetPosition(); + return; + } + if (longPressTriggeredRef.current) { + longPressTriggeredRef.current = false; + return; + } + triggerGestureFeedback('tap'); + onPress(); + }} + style={styles.pressable}> + {children} + + + {debugEnabled ? ( + + {debugLabel} + + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + wrapper: { + marginBottom: spacing.md, + }, + actionBackground: { + ...StyleSheet.absoluteFillObject, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: spacing.lg, + borderRadius: borderRadius.lg, + backgroundColor: 'rgba(99, 102, 241, 0.12)', + }, + leftActionText: { + ...typography.caption, + color: colors.accent, + fontWeight: '700', + }, + rightActionText: { + ...typography.caption, + color: colors.success, + fontWeight: '700', + }, + animatedCard: { + borderRadius: borderRadius.lg, + ...shadows.sm, + }, + pressable: { + borderRadius: borderRadius.lg, + }, + debugBadge: { + marginTop: spacing.xs, + backgroundColor: colors.surface, + borderRadius: borderRadius.md, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + debugText: { + ...typography.small, + color: colors.textSecondary, + }, +}); diff --git a/src/services/__tests__/gestureService.test.ts b/src/services/__tests__/gestureService.test.ts new file mode 100644 index 0000000..db817d2 --- /dev/null +++ b/src/services/__tests__/gestureService.test.ts @@ -0,0 +1,37 @@ +import { + buildGestureDebugLabel, + resolveGesturePriority, + validateHorizontalSwipe, +} from '../gestureService'; + +describe('gestureService', () => { + it('accepts a clear horizontal swipe', () => { + const result = validateHorizontalSwipe({ dx: 88, dy: 12, vx: 0.4, vy: 0.02 }); + + expect(result.isValid).toBe(true); + expect(result.direction).toBe('right'); + expect(result.priority).toBe('swipe'); + }); + + it('rejects vertical-dominant movement', () => { + const result = validateHorizontalSwipe({ dx: 74, dy: 64, vx: 0.27, vy: 0.35 }); + + expect(result.isValid).toBe(false); + expect(result.reason).toBe('vertical-dominant'); + }); + + it('resolves long press priority when no swipe is accepted', () => { + const swipeResult = validateHorizontalSwipe({ dx: 10, dy: 2, vx: 0.01, vy: 0 }); + + expect(resolveGesturePriority(swipeResult, true)).toBe('long-press'); + expect(resolveGesturePriority(swipeResult, false)).toBe('tap'); + }); + + it('builds a readable debug label', () => { + const result = validateHorizontalSwipe({ dx: -90, dy: 8, vx: -0.31, vy: 0.01 }); + const label = buildGestureDebugLabel(result, { dx: -90, dy: 8, vx: -0.31, vy: 0.01 }); + + expect(label).toContain('direction=left'); + expect(label).toContain('reason=accepted'); + }); +}); diff --git a/src/services/gestureService.ts b/src/services/gestureService.ts new file mode 100644 index 0000000..22cc492 --- /dev/null +++ b/src/services/gestureService.ts @@ -0,0 +1,69 @@ +import { Platform, Vibration } from 'react-native'; + +export type GestureDirection = 'left' | 'right' | 'none'; +export type GesturePriority = 'swipe' | 'long-press' | 'tap'; + +export interface GestureSample { + dx: number; + dy: number; + vx: number; + vy: number; +} + +export interface GestureValidationResult { + isValid: boolean; + direction: GestureDirection; + priority: GesturePriority; + reason: string; +} + +const SWIPE_DISTANCE_THRESHOLD = 56; +const SWIPE_VELOCITY_THRESHOLD = 0.22; +const HORIZONTAL_DOMINANCE_RATIO = 1.35; + +export function validateHorizontalSwipe(sample: GestureSample): GestureValidationResult { + const absDx = Math.abs(sample.dx); + const absDy = Math.abs(sample.dy); + const direction: GestureDirection = + sample.dx > 0 ? 'right' : sample.dx < 0 ? 'left' : 'none'; + + if (!direction || direction === 'none') { + return { isValid: false, direction: 'none', priority: 'tap', reason: 'no-horizontal-motion' }; + } + + if (absDx < SWIPE_DISTANCE_THRESHOLD && Math.abs(sample.vx) < SWIPE_VELOCITY_THRESHOLD) { + return { isValid: false, direction, priority: 'tap', reason: 'below-threshold' }; + } + + if (absDy > absDx / HORIZONTAL_DOMINANCE_RATIO) { + return { isValid: false, direction, priority: 'tap', reason: 'vertical-dominant' }; + } + + return { isValid: true, direction, priority: 'swipe', reason: 'accepted' }; +} + +export function resolveGesturePriority( + swipeResult: GestureValidationResult, + longPressTriggered: boolean +): GesturePriority { + if (swipeResult.isValid) { + return 'swipe'; + } + + return longPressTriggered ? 'long-press' : 'tap'; +} + +export function buildGestureDebugLabel( + result: GestureValidationResult, + sample: GestureSample +): string { + return `gesture=${result.priority} direction=${result.direction} dx=${sample.dx.toFixed( + 1 + )} dy=${sample.dy.toFixed(1)} vx=${sample.vx.toFixed(2)} reason=${result.reason}`; +} + +export function triggerGestureFeedback(kind: GesturePriority | 'success'): void { + const duration = kind === 'success' ? 20 : Platform.OS === 'ios' ? 10 : 15; + Vibration.vibrate(duration); +} +