diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..bed44b4 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,5 @@ +# Palette's Journal - Critical UX/Accessibility Learnings + +## 2025-05-15 - Enhancing Mobile Gestures with Haptics and ARIA +**Learning:** Swipe gestures for common actions (like pinning) are intuitive for power users but invisible to screen readers and lack physical confirmation. Adding `expo-haptics` provides tactile feedback that makes the gesture feel "mechanical" and satisfying. +**Action:** Always pair continuous gestures with haptic feedback at thresholds and provide `accessibilityActions` so screen reader users can trigger the same logic via their specialized menus. diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index a74f666..47a0818 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -129,6 +129,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', + flightAccessibilityUnpinHint: 'Rimuove il volo dalla sezione pinnati', + flightFrom: 'da', flightTo: 'per', // Phonebook phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi', contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti', @@ -314,6 +318,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', + 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 6e60b13..1a5038b 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -7,6 +7,7 @@ import { import { Easing } 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'; @@ -101,13 +102,14 @@ const SWIPE_MAX_TRANSLATE = 96; const SWIPE_DRAG_RESISTANCE = 0.82; function SwipeableFlightCardComponent({ - children, isPinned, onToggle, + children, isPinned, onToggle, ...props }: { children: React.ReactNode; isPinned: boolean; onToggle: () => void; -}) { +} & React.ComponentProps) { const translateX = useRef(new Animated.Value(0)).current; + const hasTriggeredHaptic = useRef(false); const onToggleRef = useRef(onToggle); onToggleRef.current = onToggle; const dragScale = useMemo(() => translateX.interpolate({ @@ -135,9 +137,17 @@ function SwipeableFlightCardComponent({ ? Math.max(g.dx * SWIPE_DRAG_RESISTANCE, -SWIPE_MAX_TRANSLATE) : g.dx * 0.08; translateX.setValue(nextTranslate); + + if (nextTranslate <= -SWIPE_THRESHOLD && !hasTriggeredHaptic.current) { + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + hasTriggeredHaptic.current = true; + } else if (nextTranslate > -SWIPE_THRESHOLD && hasTriggeredHaptic.current) { + hasTriggeredHaptic.current = false; + } }, onPanResponderRelease: (_, g) => { if (g.dx < -SWIPE_THRESHOLD || g.vx < -SWIPE_TRIGGER_VELOCITY) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); Animated.timing(translateX, { toValue: -SWIPE_MAX_TRANSLATE, duration: 170, @@ -150,6 +160,7 @@ function SwipeableFlightCardComponent({ } else { animateBack(g.vx); } + hasTriggeredHaptic.current = false; }, onPanResponderTerminate: () => { animateBack(); @@ -158,7 +169,11 @@ function SwipeableFlightCardComponent({ return ( - + {children} @@ -221,6 +236,24 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, const flightId = item.flight?.identification?.number?.default || null; const isPinned = flightId !== null && flightId === pinnedFlightId; + const accessibilityLabel = useMemo(() => { + const direction = activeTab === 'arrivals' ? t('flightFrom') : t('flightTo'); + const pinned = isPinned ? `${t('flightPinnedLabel')}. ` : ''; + return `${pinned}${flightNumber}, ${airline}. ${direction} ${originDest}, ${time}. ${statusText}.`; + }, [activeTab, flightNumber, airline, originDest, time, statusText, isPinned, t]); + + const accessibilityActions = useMemo(() => [ + { name: isPinned ? 'unpin' : 'pin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') } + ], [isPinned, t]); + + const onAccessibilityAction = useCallback((event: { nativeEvent: { actionName: string } }) => { + if (event.nativeEvent.actionName === 'pin') { + onPin(item); + } else if (event.nativeEvent.actionName === 'unpin') { + onUnpin(); + } + }, [item, onPin, onUnpin]); + const normFn = normalizeFlightNumber(flightNumber); const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase(); const normFnStripped = normalizeForMatching(normFn); @@ -258,6 +291,11 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, isPinned ? onUnpin() : onPin(item)} + accessible={true} + accessibilityLabel={accessibilityLabel} + accessibilityHint={isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint')} + accessibilityActions={accessibilityActions} + onAccessibilityAction={onAccessibilityAction} > {isPinned && {t('flightPinned')}} @@ -1052,7 +1090,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" >