diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..8f68cda --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,9 @@ +# Palette's Journal - Critical Learnings Only + +## 2025-05-15 - Haptic Feedback in Continuous Gestures +**Learning:** In 'PanResponder' implementations, haptic feedback can easily misfire or trigger repeatedly during a single swipe if not guarded by a state ref. +**Action:** Use a 'useRef' toggle (e.g., 'hasTriggeredHaptic') to ensure the feedback triggers only once when a threshold is crossed during a continuous gesture. + +## 2025-05-15 - Accessibility for Swipe Actions +**Learning:** Swipe-based actions (like pinning) are inaccessible to screen reader users. +**Action:** Use 'accessibilityActions' and 'onAccessibilityAction' on the card component to provide an alternative way for assistive technologies to trigger these specific interactions without requiring complex gestures. diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 8ccd1e2..6119713 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 note', // TravelDoc traveldocSub: 'Verifica documenti di viaggio', traveldocLoading: 'Caricamento TravelDoc…', @@ -106,6 +108,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 volo', + flightAccessibilityPinHint: 'Sposta il volo in cima alla lista e abilita il monitoraggio', + flightAccessibilityUnpinHint: 'Rimuovi il volo dalla posizione in cima', // Phonebook phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi', contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti', @@ -198,6 +204,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 notes', // TravelDoc traveldocSub: 'Travel document check', traveldocLoading: 'Loading TravelDoc…', @@ -264,6 +272,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: 'Move flight to the top of the list and enable tracking', + flightAccessibilityUnpinHint: 'Remove flight from the top position', // 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 7b37ca6..01fda35 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, + 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,36 @@ 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; + [key: string]: any; }) { 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 (Math.abs(g.dx) > SWIPE_THRESHOLD && !hasTriggeredHaptic.current) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + hasTriggeredHaptic.current = true; + } else if (Math.abs(g.dx) < SWIPE_THRESHOLD && hasTriggeredHaptic.current) { + hasTriggeredHaptic.current = false; + } + } }, onPanResponderRelease: (_, g) => { + hasTriggeredHaptic.current = false; 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(); @@ -94,7 +108,11 @@ function SwipeableFlightCardComponent({ return ( - + {children} @@ -605,10 +623,23 @@ export default function FlightScreen() { console.log(`[FlightScreen] No staffMonitor match for "${normFn}" (stripped: "${normFnStripped}") in ${activeTab}`); } + const accessibilityLabel = `${isPinned ? t('flightPinnedLabel') + ', ' : ''}${flightNumber}, ${airline}, ${activeTab === 'arrivals' ? t('homeArrival') : t('homeDeparture')}, ${originDest}, ${time}, ${statusText}`; + return ( isPinned ? unpinFlight() : pinFlight(item)} + accessible + accessibilityLabel={accessibilityLabel} + accessibilityHint={isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint')} + accessibilityActions={[ + { name: 'togglePin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') }, + ]} + onAccessibilityAction={(event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'togglePin') { + isPinned ? unpinFlight() : pinFlight(item); + } + }} > {isPinned && {t('flightPinned')}} @@ -769,7 +800,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" > { await AsyncStorage.setItem(STORAGE_KEY, text); setSaved(true); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }, [text]); const clear = useCallback(() => { @@ -109,12 +111,20 @@ export default function NotepadScreen() { {t('notepadTitle')} - + {saved ? t('notepadSaved') : t('notepadSave')}