diff --git a/.jules/palette.md b/.jules/palette.md
new file mode 100644
index 0000000..6049e9d
--- /dev/null
+++ b/.jules/palette.md
@@ -0,0 +1,3 @@
+## 2025-05-14 - Enhancing Custom Gestures with Haptics and Accessibility
+**Learning:** Custom gesture interactions, like swiping on a flight card, can be opaque to users if they lack sensory feedback (haptics) or a programmatic way to be triggered (accessibility actions). Adding haptics provides a tactile confirmation of threshold crossing, while accessibility actions make the same functionality available to screen reader users.
+**Action:** When implementing custom PanResponders, always include haptic feedback for threshold crossing and define equivalent accessibilityActions for non-visual navigation.
diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts
index 8ccd1e2..288b714 100644
--- a/src/i18n/translations.ts
+++ b/src/i18n/translations.ts
@@ -106,6 +106,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: 'Aggiunge il volo alle notifiche e alla visualizzazione rapida',
+ flightAccessibilityUnpinHint: 'Rimuove il volo dalle notifiche e dalla visualizzazione rapida',
+ flightFrom: 'da', flightTo: 'per',
// Phonebook
phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi',
contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti',
@@ -264,6 +268,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 notifications and quick view',
+ flightAccessibilityUnpinHint: 'Removes the flight from notifications and quick view',
+ 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 7b37ca6..782ce03 100644
--- a/src/screens/FlightScreen.tsx
+++ b/src/screens/FlightScreen.tsx
@@ -3,7 +3,9 @@ import {
View, Text, StyleSheet, ActivityIndicator, Modal,
FlatList, TouchableOpacity, RefreshControl, Image, Alert,
Animated, PanResponder, NativeModules, Platform,
+ AccessibilityActionEvent,
} from 'react-native';
+import * as Haptics from 'expo-haptics';
import * as Calendar from 'expo-calendar';
import * as Notifications from 'expo-notifications';
import AsyncStorage from '@react-native-async-storage/async-storage';
@@ -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 (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 +100,17 @@ 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 +621,19 @@ 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('flightFrom') : t('flightTo')} ${originDest}, ${time}, ${statusText}`;
+
return (
isPinned ? unpinFlight() : pinFlight(item)}
+ accessible
+ accessibilityLabel={accessibilityLabel}
+ accessibilityActions={[{ name: 'toggle_pin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') }]}
+ onAccessibilityAction={(e: AccessibilityActionEvent) => {
+ if (e.nativeEvent.actionName === 'toggle_pin') isPinned ? unpinFlight() : pinFlight(item);
+ }}
+ accessibilityHint={isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint')}
>
{isPinned && {t('flightPinned')}}