From 6c5260ae6a1ee1db04aa33eb7a90093087bfc790 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 00:13:58 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Palette:=20Enhance=20Flight=20Ca?= =?UTF-8?q?rd=20UX=20with=20haptics=20and=20accessibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add haptic feedback to swipe-to-pin gesture (Impact on threshold, Success on completion) - Implement comprehensive accessibility labels for flight cards - Add accessibility actions to allow screen reader users to pin/unpin flights - Add necessary translations for new accessibility strings - Improve TypeScript types for SwipeableFlightCard using ViewProps Co-authored-by: TargetMisser <52361977+TargetMisser@users.noreply.github.com> --- .jules/palette.md | 3 +++ src/i18n/translations.ts | 4 ++++ src/screens/FlightScreen.tsx | 46 ++++++++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .jules/palette.md diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..857859e --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2025-05-14 - Haptic & Accessibility for Swipe Gestures +**Learning:** Swipe gestures are delightfull but inherently inaccessible to screen reader users. Adding haptic feedback (Medium impact on threshold, Success on completion) provides tactile confirmation for sighted users. +**Action:** Always pair swipe-to-action gestures with `accessibilityActions` and `onAccessibilityAction` to ensure screen reader users can trigger the same functionality via the accessibility menu. Use `ViewProps` to safely pass accessibility attributes to underlying views. diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 8ccd1e2..9c557d5 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -98,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', + flightAccessibilityPin: 'Fissa volo', flightAccessibilityUnpin: 'Rimuovi fissaggio volo', + flightFrom: 'da', flightTo: 'per', flightNotifEnabled: 'Notifiche attivate', flightNotifPermDenied: 'Permesso negato', flightNotifPermMsg: 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.', @@ -256,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', + flightAccessibilityPin: 'Pin flight', flightAccessibilityUnpin: 'Unpin flight', + 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 7b37ca6..e27a255 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, ViewProps, } 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,34 @@ function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineN const SWIPE_THRESHOLD = 80; function SwipeableFlightCardComponent({ - children, isPinned, onToggle, + children, isPinned, onToggle, ...props }: { children: React.ReactNode; isPinned: boolean; onToggle: () => void; -}) { +} & ViewProps) { 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 = 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 +98,21 @@ 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; }, }), []); return ( - + {children} @@ -605,10 +623,30 @@ export default function FlightScreen() { console.log(`[FlightScreen] No staffMonitor match for "${normFn}" (stripped: "${normFnStripped}") in ${activeTab}`); } + const accessibilityLabel = [ + isPinned ? t('flightPinned') : '', + flightNumber, + airline, + activeTab === 'arrivals' ? t('flightFrom') : t('flightTo'), + originDest, + time, + statusText, + ].filter(Boolean).join(', '); + + const onAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'togglePin') { + isPinned ? unpinFlight() : pinFlight(item); + } + }; + return ( isPinned ? unpinFlight() : pinFlight(item)} + accessible={true} + accessibilityLabel={accessibilityLabel} + accessibilityActions={[{ name: 'togglePin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') }]} + onAccessibilityAction={onAccessibilityAction} > {isPinned && {t('flightPinned')}}