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')}}