diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..7373af1 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2025-05-14 - Flight Card Accessibility & Interaction +**Learning:** Swipe-based actions are common in mobile UX but are completely inaccessible to screen reader users unless mapped to `accessibilityActions`. +**Action:** When implementing gestures like swipe-to-action, always include `accessibilityActions` and `onAccessibilityAction` to ensure functional parity for all users. diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 777bce5..4ffe75a 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -106,6 +106,10 @@ const it = { flightNotifMsg1: 'Programmate {count} notifiche: arrivi voli (15 min prima) + fine turno.', flightNotifMsg0: 'Nessun volo futuro trovato, ma riceverai la notifica di fine turno.', flightNotifAccessEnable: 'Attiva notifiche voli', flightNotifAccessDisable: 'Disattiva notifiche voli', + flightAccessibilityPin: 'Pinna volo', flightAccessibilityUnpin: 'Rimuovi pin', + flightAccessibilityPinHint: 'Aggiunge il volo alla sezione pinnati e attiva le notifiche dedicate', + flightAccessibilityUnpinHint: 'Rimuove il volo dalla sezione pinnati', + flightFrom: 'Da', flightTo: 'Per', // Phonebook phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi', contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti', @@ -266,6 +270,10 @@ const en: typeof it = { flightNotifMsg1: '{count} notifications scheduled: flight arrivals (15 min before) + end of shift.', flightNotifMsg0: 'No future flights found, but you will receive the end-of-shift notification.', flightNotifAccessEnable: 'Enable flight notifications', flightNotifAccessDisable: 'Disable flight notifications', + flightAccessibilityPin: 'Pin flight', flightAccessibilityUnpin: 'Unpin flight', + flightAccessibilityPinHint: 'Adds the flight to the pinned section and enables dedicated notifications', + flightAccessibilityUnpinHint: 'Removes the flight from the pinned section', + flightFrom: 'From', flightTo: 'To', // Phonebook phonebookTitle: 'Phonebook', contactAdd: 'Add', contactSearch: 'Search name or number...', contactAll: 'All', diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index e200b90..40165b3 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -3,8 +3,10 @@ import { View, Text, StyleSheet, ActivityIndicator, Modal, ScrollView, FlatList, TouchableOpacity, RefreshControl, Image, Alert, Animated, PanResponder, NativeModules, Platform, + AccessibilityActionEvent, ViewProps, } from 'react-native'; import * as Calendar from 'expo-calendar'; +import * as Haptics from 'expo-haptics'; import * as Notifications from 'expo-notifications'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; @@ -78,27 +80,41 @@ function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineN const SWIPE_THRESHOLD = 80; -function SwipeableFlightCardComponent({ - children, isPinned, onToggle, -}: { +interface SwipeableFlightCardProps extends ViewProps { children: React.ReactNode; isPinned: boolean; onToggle: () => void; -}) { +} + +function SwipeableFlightCardComponent({ + children, isPinned, onToggle, ...rest +}: SwipeableFlightCardProps) { 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); + hasTriggeredHaptic.current = true; + } else if (g.dx >= -SWIPE_THRESHOLD && hasTriggeredHaptic.current) { + hasTriggeredHaptic.current = false; + } + } }, onPanResponderRelease: (_, g) => { + hasTriggeredHaptic.current = false; if (g.dx < -SWIPE_THRESHOLD) { Animated.timing(translateX, { toValue: -SWIPE_THRESHOLD, duration: 100, useNativeDriver: true }).start(() => { onToggleRef.current(); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start(); }); } else { @@ -106,13 +122,14 @@ function SwipeableFlightCardComponent({ } }, onPanResponderTerminate: () => { + hasTriggeredHaptic.current = false; Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); }, }), []); return ( - + {children} @@ -182,10 +199,33 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, smPool.find(sm => sm.flightNumber === normFn) ?? smPool.find(sm => normalizeForMatching(sm.flightNumber) === normFnStripped); + const accessibilityLabel = [ + isPinned ? t('flightPinnedLabel') : '', + flightNumber, + airline, + activeTab === 'arrivals' ? t('flightFrom') : t('flightTo'), + originDest, + time, + statusText, + ].filter(Boolean).join(', '); + + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'togglePin') { + isPinned ? onUnpin() : onPin(item); + } + }; + return ( isPinned ? onUnpin() : onPin(item)} + accessible={true} + accessibilityLabel={accessibilityLabel} + accessibilityActions={[ + { name: 'togglePin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') }, + ]} + accessibilityHint={isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint')} + onAccessibilityAction={handleAccessibilityAction} > {isPinned && {t('flightPinned')}} @@ -874,7 +914,7 @@ export default function FlightScreen() { onPress={toggleNotifications} activeOpacity={0.8} accessible - accessibilityLabel={notifsEnabled ? 'Disattiva notifiche voli' : 'Attiva notifiche voli'} + accessibilityLabel={notifsEnabled ? t('flightNotifAccessDisable') : t('flightNotifAccessEnable')} accessibilityRole="button" >