From f7405cdc22a0ce111fac9379a8048b655a170a8a Mon Sep 17 00:00:00 2001 From: Itodo-S Date: Wed, 22 Apr 2026 23:24:45 +0100 Subject: [PATCH] feat: improve gesture handling --- App.tsx | 5 +- src/components/common/SwipeableCard.tsx | 221 ++++++++++++++++++ src/components/home/SubscriptionList.tsx | 3 + .../subscription/SubscriptionCard.tsx | 191 ++++++++------- src/screens/HomeScreen.tsx | 10 + src/services/__tests__/gestureService.test.ts | 37 +++ src/services/gestureService.ts | 69 ++++++ 7 files changed, 453 insertions(+), 83 deletions(-) create mode 100644 src/components/common/SwipeableCard.tsx create mode 100644 src/services/__tests__/gestureService.test.ts create mode 100644 src/services/gestureService.ts diff --git a/App.tsx b/App.tsx index 975293c..4fbff96 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,6 @@ import React from 'react'; 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'; @@ -72,13 +73,13 @@ 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/components/home/SubscriptionList.tsx b/src/components/home/SubscriptionList.tsx index 675add1..9342bb6 100644 --- a/src/components/home/SubscriptionList.tsx +++ b/src/components/home/SubscriptionList.tsx @@ -15,6 +15,7 @@ interface SubscriptionListProps { onSubscriptionPress: (sub: Subscription) => void; onToggleStatus: (id: string) => void; onAddFirstPress: () => void; + debugGestures?: boolean; } export const SubscriptionList: React.FC = ({ @@ -28,6 +29,7 @@ export const SubscriptionList: React.FC = ({ onSubscriptionPress, onToggleStatus, onAddFirstPress, + debugGestures = false, }) => { return ( @@ -91,6 +93,7 @@ export const SubscriptionList: React.FC = ({ subscription={subscription} onPress={onSubscriptionPress} onToggleStatus={onToggleStatus} + debugGestures={debugGestures} /> ))} diff --git a/src/components/subscription/SubscriptionCard.tsx b/src/components/subscription/SubscriptionCard.tsx index 93de5aa..5adc2e7 100644 --- a/src/components/subscription/SubscriptionCard.tsx +++ b/src/components/subscription/SubscriptionCard.tsx @@ -1,5 +1,12 @@ import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Alert, + GestureResponderEvent, +} from 'react-native'; import { colors, spacing, typography, borderRadius, shadows } from '../../utils/constants'; import { Subscription } from '../../types/subscription'; import { @@ -14,17 +21,20 @@ import { getBillingCycleColor, isUpcomingBilling, } from '../../utils/subscriptionHelpers'; +import { SwipeableCard } from '../common/SwipeableCard'; export interface SubscriptionCardProps { subscription: Subscription; onPress: (subscription: Subscription) => void; onToggleStatus?: (id: string) => void; + debugGestures?: boolean; } export const SubscriptionCard: React.FC = ({ subscription, onPress, onToggleStatus, + debugGestures = false, }) => { const handleToggleStatus = () => { if (onToggleStatus) { @@ -39,104 +49,124 @@ export const SubscriptionCard: React.FC = ({ } }; + const handleLongPress = () => { + Alert.alert(subscription.name, 'Choose a quick action for this subscription.', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Open details', onPress: () => onPress(subscription) }, + { + text: subscription.isActive ? 'Pause' : 'Activate', + onPress: () => handleToggleStatus(), + }, + ]); + }; + const upcoming = isUpcomingBilling(subscription.nextBillingDate); return ( - onPress(subscription)} - activeOpacity={0.8}> - - - {getCategoryIcon(subscription.category)} - + onSwipeLeft={() => onPress(subscription)} + onSwipeRight={() => handleToggleStatus()}> + + + + {getCategoryIcon(subscription.category)} + - - - {subscription.name} - - - {formatCategory(subscription.category)} - - + + + {subscription.name} + + + {formatCategory(subscription.category)} + + - - {subscription.isCryptoEnabled && ( - - - - )} + accessible={true} + accessibilityLabel={ + subscription.isActive ? 'Subscription active' : 'Subscription paused' + } + style={styles.statusContainer}> + + {subscription.isCryptoEnabled && ( + + + + )} + - - - - - {formatCurrency(subscription.price, subscription.currency)} - - - /{formatBillingCycle(subscription.billingCycle)} - - + + + + {formatCurrency(subscription.price, subscription.currency)} + + + /{formatBillingCycle(subscription.billingCycle)} + + - - Next billing: - - {formatRelativeDate(new Date(subscription.nextBillingDate))} - + + Next billing: + + {formatRelativeDate(new Date(subscription.nextBillingDate))} + + - - {subscription.description && ( - - {subscription.description} - - )} + {subscription.description && ( + + {subscription.description} + + )} - {onToggleStatus && ( - - {subscription.isActive ? 'Pause' : 'Activate'} - - )} - + {onToggleStatus && ( + { + event.stopPropagation(); + handleToggleStatus(); + }} + activeOpacity={0.7} + accessibilityRole="button" + accessibilityLabel={ + subscription.isActive + ? `Pause ${subscription.name}` + : `Activate ${subscription.name}` + }> + {subscription.isActive ? 'Pause' : 'Activate'} + + )} + + ); }; @@ -145,7 +175,6 @@ const styles = StyleSheet.create({ backgroundColor: colors.surface, borderRadius: borderRadius.lg, padding: spacing.md, - marginBottom: spacing.md, borderWidth: 1, borderColor: colors.border, ...shadows.sm, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index c442ac5..d892992 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -63,6 +63,9 @@ const HomeScreen: React.FC = () => { SubTrackr Manage your subscriptions + + Swipe right to toggle a subscription or long-press a card for quick actions. + { onSubscriptionPress={(sub) => navigation.navigate('SubscriptionDetail', { id: sub.id })} onToggleStatus={handleToggleStatus} onAddFirstPress={() => navigation.navigate('AddSubscription')} + debugGestures={__DEV__} /> @@ -126,6 +130,12 @@ const styles = StyleSheet.create({ header: { padding: spacing.lg, paddingBottom: spacing.md }, title: { ...typography.h1, color: colors.text, marginBottom: spacing.xs }, subtitle: { ...typography.body, color: colors.textSecondary }, + gestureHint: { + ...typography.caption, + color: colors.accent, + marginTop: spacing.xs, + marginBottom: spacing.md, + }, errorContainer: { backgroundColor: colors.error, padding: spacing.md, 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); +} +