diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..e6e496d --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2025-05-14 - Haptics and Accessibility for Swipeable Cards +**Learning:** Swipe gestures are non-discoverable and inaccessible for screen reader users. Adding haptic feedback makes the gesture feel "physical" and deliberate, while `accessibilityActions` provide a semantic way to perform the same action without swiping. +**Action:** Always pair custom gestures with `accessibilityActions` and `Haptics` to ensure the interface is both delightful and accessible. diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 777bce5..3fb794f 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -98,6 +98,10 @@ const it = { flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Nastro', flightDeparted: 'Partito', flightLanded: 'Atterrato', flightEstimated: 'Stimato', flightOnTime: 'In orario', flightPinned: 'PINNATO', flightPinnedLabel: 'Pinnato', + flightAccessibilityPin: 'Pinna volo', flightAccessibilityUnpin: 'Rimuovi pin', + flightAccessibilityPinHint: 'Fissa il volo in alto e attiva le notifiche', + flightAccessibilityUnpinHint: 'Rimuove il volo dalla sezione in primo piano', + flightFrom: 'da', flightTo: 'a', flightNotifEnabled: 'Notifiche attivate', flightNotifPermDenied: 'Permesso negato', flightNotifPermMsg: 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.', @@ -258,6 +262,10 @@ const en: typeof it = { flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Belt', flightDeparted: 'Departed', flightLanded: 'Landed', flightEstimated: 'Estimated', flightOnTime: 'On time', flightPinned: 'PINNED', flightPinnedLabel: 'Pinned', + flightAccessibilityPin: 'Pin flight', flightAccessibilityUnpin: 'Unpin flight', + flightAccessibilityPinHint: 'Pins the flight to the top and enables notifications', + flightAccessibilityUnpinHint: 'Removes the flight from the pinned section', + flightFrom: 'from', flightTo: 'to', flightNotifEnabled: 'Notifications enabled', flightNotifPermDenied: 'Permission denied', flightNotifPermMsg: 'Enable notifications in phone settings to use this feature.', diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index e200b90..aa39671 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -6,6 +6,7 @@ import { } from 'react-native'; import * as Calendar from 'expo-calendar'; import * as Notifications from 'expo-notifications'; +import * as Haptics from 'expo-haptics'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; @@ -79,24 +80,35 @@ function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineN const SWIPE_THRESHOLD = 80; function SwipeableFlightCardComponent({ - children, isPinned, onToggle, + children, isPinned, onToggle, ...rest }: { children: React.ReactNode; isPinned: boolean; onToggle: () => void; -}) { +} & React.ComponentProps) { const translateX = useRef(new Animated.Value(0)).current; const onToggleRef = useRef(onToggle); onToggleRef.current = onToggle; + const hasTriggeredHaptic = useRef(false); const panResponder = useMemo(() => PanResponder.create({ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 15 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5, onPanResponderMove: (_, g) => { - if (g.dx < 0) translateX.setValue(g.dx); + if (g.dx < 0) { + translateX.setValue(g.dx); + if (g.dx < -SWIPE_THRESHOLD && !hasTriggeredHaptic.current) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); + hasTriggeredHaptic.current = true; + } else if (g.dx >= -SWIPE_THRESHOLD && hasTriggeredHaptic.current) { + hasTriggeredHaptic.current = false; + } + } }, onPanResponderRelease: (_, g) => { + hasTriggeredHaptic.current = false; if (g.dx < -SWIPE_THRESHOLD) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); Animated.timing(translateX, { toValue: -SWIPE_THRESHOLD, duration: 100, useNativeDriver: true }).start(() => { onToggleRef.current(); Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start(); @@ -106,13 +118,18 @@ function SwipeableFlightCardComponent({ } }, onPanResponderTerminate: () => { + hasTriggeredHaptic.current = false; Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); }, }), []); return ( - + {children} @@ -175,6 +192,8 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, const flightId = item.flight?.identification?.number?.default || null; const isPinned = flightId !== null && flightId === pinnedFlightId; + const accessibilityLabel = `${isPinned ? t('flightPinnedLabel') + ', ' : ''}${flightNumber}, ${airline}, ${activeTab === 'arrivals' ? t('flightFrom') : t('flightTo')} ${originDest}, ${time}, ${statusText}`; + const normFn = normalizeFlightNumber(flightNumber); const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase(); const normFnStripped = normalizeForMatching(normFn); @@ -186,6 +205,17 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, isPinned ? onUnpin() : onPin(item)} + accessible={true} + accessibilityLabel={accessibilityLabel} + accessibilityActions={[ + { name: 'togglePin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') }, + ]} + onAccessibilityAction={(event) => { + if (event.nativeEvent.actionName === 'togglePin') { + isPinned ? onUnpin() : onPin(item); + } + }} + accessibilityHint={isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint')} > {isPinned && {t('flightPinned')}}