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);
+}
+