Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .jules/palette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-05-14 - Haptics and Accessibility for Swipeable Cards
**Learning:** Swipe gestures are non-discoverable and inaccessible for screen reader users. Adding haptic feedback makes the gesture feel "physical" and deliberate, while `accessibilityActions` provide a semantic way to perform the same action without swiping.
**Action:** Always pair custom gestures with `accessibilityActions` and `Haptics` to ensure the interface is both delightful and accessible.
8 changes: 8 additions & 0 deletions src/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ 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: 'Pinna volo', flightAccessibilityUnpin: 'Rimuovi pin',
flightAccessibilityPinHint: 'Fissa il volo in alto e attiva le notifiche',
flightAccessibilityUnpinHint: 'Rimuove il volo dalla sezione in primo piano',
flightFrom: 'da', flightTo: 'a',
flightNotifEnabled: 'Notifiche attivate',
flightNotifPermDenied: 'Permesso negato',
flightNotifPermMsg: 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.',
Expand Down Expand Up @@ -258,6 +262,10 @@ 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',
flightAccessibilityPinHint: 'Pins the flight to the top and enables notifications',
flightAccessibilityUnpinHint: 'Removes the flight from the pinned section',
flightFrom: 'from', flightTo: 'to',
flightNotifEnabled: 'Notifications enabled',
flightNotifPermDenied: 'Permission denied',
flightNotifPermMsg: 'Enable notifications in phone settings to use this feature.',
Expand Down
38 changes: 34 additions & 4 deletions src/screens/FlightScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} 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';
Expand Down Expand Up @@ -79,24 +80,35 @@ 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;
}) {
} & React.ComponentProps<typeof Animated.View>) {
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).catch(() => {});
hasTriggeredHaptic.current = true;
} else if (g.dx >= -SWIPE_THRESHOLD && hasTriggeredHaptic.current) {
hasTriggeredHaptic.current = false;
}
}
},
onPanResponderRelease: (_, g) => {
hasTriggeredHaptic.current = false;
if (g.dx < -SWIPE_THRESHOLD) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {});
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();
Expand All @@ -106,13 +118,18 @@ function SwipeableFlightCardComponent({
}
},
onPanResponderTerminate: () => {
hasTriggeredHaptic.current = false;
Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start();
},
}), []);

return (
<View style={{ marginBottom: 10 }}>
<Animated.View style={{ transform: [{ translateX }] }} {...panResponder.panHandlers}>
<Animated.View
style={{ transform: [{ translateX }] }}
{...panResponder.panHandlers}
{...rest}
>
{children}
</Animated.View>
</View>
Expand Down Expand Up @@ -175,6 +192,8 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin,
const flightId = item.flight?.identification?.number?.default || null;
const isPinned = flightId !== null && flightId === pinnedFlightId;

const accessibilityLabel = `${isPinned ? t('flightPinnedLabel') + ', ' : ''}${flightNumber}, ${airline}, ${activeTab === 'arrivals' ? t('flightFrom') : t('flightTo')} ${originDest}, ${time}, ${statusText}`;

const normFn = normalizeFlightNumber(flightNumber);
const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase();
const normFnStripped = normalizeForMatching(normFn);
Expand All @@ -186,6 +205,17 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin,
<SwipeableFlightCard
isPinned={isPinned}
onToggle={() => isPinned ? onUnpin() : onPin(item)}
accessible={true}
accessibilityLabel={accessibilityLabel}
accessibilityActions={[
{ name: 'togglePin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') },
]}
onAccessibilityAction={(event) => {
if (event.nativeEvent.actionName === 'togglePin') {
isPinned ? onUnpin() : onPin(item);
}
}}
accessibilityHint={isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint')}
>
<View style={[s.card, isPinned && s.cardPinned, { marginBottom: 0 }]}>
{isPinned && <View style={s.pinBanner}><Text style={s.pinBannerText}>{t('flightPinned')}</Text></View>}
Expand Down
Loading