diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..e1d94f7 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,7 @@ +## 2025-05-14 - Interactive Card Accessibility +**Learning:** For interactive elements like swipeable cards that lack a standard 'onPress', 'accessibilityRole="button"' can be misleading. Using 'accessibilityActions' provides a clearer experience for screen reader users by exposing custom interactions (like Pin/Unpin) as menu options. +**Action:** Use 'accessibilityActions' and 'onAccessibilityAction' for non-standard gestures, and ensure a descriptive 'accessibilityLabel' summarizes the component's state. + +## 2025-05-14 - Haptic Feedback in Gestures +**Learning:** Triggering haptic feedback continuously during a 'PanResponder' gesture creates a jittery experience. Using a 'useRef' toggle ensures feedback only occurs exactly once when a threshold is crossed. +**Action:** Implement a 'hasTriggeredHaptic' ref to guard haptic calls in continuous interaction loops. diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index a4b48b8..054d225 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -40,6 +40,8 @@ const it = { notepadClearTitle: 'Cancella note', notepadClearMsg: 'Sei sicuro di voler cancellare tutte le note?', notepadClearConfirm: 'Cancella', notepadPlaceholder: 'Inizia a scrivere...', + notepadAccessibilityClear: 'Cancella tutte le note', + notepadAccessibilitySave: 'Salva le note attuali', // TravelDoc traveldocSub: 'Verifica documenti di viaggio', traveldocLoading: 'Caricamento TravelDoc…', @@ -96,6 +98,8 @@ const it = { flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Nastro', flightDeparted: 'Partito', flightLanded: 'Atterrato', flightEstimated: 'Stimato', flightOnTime: 'In orario', flightPinned: 'PINNATO', flightPinnedLabel: 'Pinnato', + flightActionPin: 'Fissa in alto', + flightActionUnpin: 'Rimuovi dall\'alto', flightNotifEnabled: 'Notifiche attivate', flightNotifPermDenied: 'Permesso negato', flightNotifPermMsg: 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.', @@ -196,6 +200,8 @@ const en: typeof it = { notepadClearTitle: 'Clear notes', notepadClearMsg: 'Are you sure you want to clear all notes?', notepadClearConfirm: 'Clear', notepadPlaceholder: 'Start writing...', + notepadAccessibilityClear: 'Clear all notes', + notepadAccessibilitySave: 'Save current notes', // TravelDoc traveldocSub: 'Travel document check', traveldocLoading: 'Loading TravelDoc…', @@ -252,6 +258,8 @@ 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', + flightActionPin: 'Pin to top', + flightActionUnpin: 'Unpin from top', 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 814dd4a..198ed8e 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -3,9 +3,11 @@ import { View, Text, StyleSheet, ActivityIndicator, Modal, FlatList, TouchableOpacity, RefreshControl, Image, Alert, Animated, PanResponder, NativeModules, Platform, + type AccessibilityActionEvent, } 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'; @@ -61,24 +63,37 @@ function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineN const SWIPE_THRESHOLD = 80; function SwipeableFlightCardComponent({ - children, isPinned, onToggle, + children, isPinned, onToggle, accessibilityLabel, }: { children: React.ReactNode; isPinned: boolean; onToggle: () => void; + accessibilityLabel?: string; }) { const translateX = useRef(new Animated.Value(0)).current; const onToggleRef = useRef(onToggle); onToggleRef.current = onToggle; + const hasTriggeredHaptic = useRef(false); + const { t } = useLanguage(); + 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.selectionAsync(); + hasTriggeredHaptic.current = true; + } else if (g.dx >= -SWIPE_THRESHOLD && hasTriggeredHaptic.current) { + hasTriggeredHaptic.current = false; + } + } }, onPanResponderRelease: (_, g) => { if (g.dx < -SWIPE_THRESHOLD) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); 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(); @@ -86,15 +101,30 @@ function SwipeableFlightCardComponent({ } else { Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start(); } + hasTriggeredHaptic.current = false; }, onPanResponderTerminate: () => { Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); + hasTriggeredHaptic.current = false; }, }), []); + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'togglePin') { + onToggleRef.current(); + } + }; + return ( - + {children} @@ -597,10 +627,13 @@ export default function FlightScreen() { console.log(`[FlightScreen] No staffMonitor match for "${normFn}" (stripped: "${normFnStripped}") in ${activeTab}`); } + const a11yLabel = `${isPinned ? t('flightPinnedLabel') + ', ' : ''}${flightNumber}, ${airline}, ${activeTab === 'arrivals' ? t('homeArrival') : t('homeDeparture')} ${t('homeToday')} ${originDest}, ${time}, ${statusText}`; + return ( isPinned ? unpinFlight() : pinFlight(item)} + accessibilityLabel={a11yLabel} > {isPinned && {t('flightPinned')}} @@ -761,7 +794,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" > {t('notepadTitle')} - + {saved ? t('notepadSaved') : t('notepadSave')}