diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 27b8f9bb..fbd7a1f2 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -165,6 +165,13 @@ 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 dai fissati', + flightAccessibilityPinHint: 'Aggiunge il volo alla sezione in evidenza e attiva le notifiche dedicate.', + flightAccessibilityUnpinHint: 'Rimuove il volo dalla sezione in evidenza.', + flightFrom: 'da', + flightTo: 'per', + flightOpenExternal: 'Apre i dettagli del volo su Flightradar24', flightNotifEnabled: 'Notifiche attivate', flightNotifPermDenied: 'Permesso negato', flightNotifPermMsg: 'Abilita le notifiche nelle impostazioni del telefono per usare questa funzione.', @@ -408,6 +415,13 @@ 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: 'Adds the flight to the pinned section and enables dedicated notifications.', + flightAccessibilityUnpinHint: 'Removes the flight from the pinned section.', + flightFrom: 'from', + flightTo: 'to', + flightOpenExternal: 'Opens flight details on Flightradar24', 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 faa8b180..7ef4dfbb 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -3,8 +3,10 @@ import { View, Text, StyleSheet, ActivityIndicator, Modal, ScrollView, FlatList, TouchableOpacity, RefreshControl, Image, Animated, PanResponder, NativeModules, Platform, Switch, Linking, + type ViewProps, type AccessibilityActionEvent, } from 'react-native'; import { Easing } 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'; @@ -328,13 +330,14 @@ const SWIPE_MAX_TRANSLATE = 96; const SWIPE_DRAG_RESISTANCE = 0.82; function SwipeableFlightCardComponent({ - children, isPinned, onToggle, + children, isPinned, onToggle, ...rest }: { children: React.ReactNode; isPinned: boolean; onToggle: () => void; -}) { +} & ViewProps) { const translateX = useRef(new Animated.Value(0)).current; + const hasTriggeredHaptic = useRef(false); const onToggleRef = useRef(onToggle); onToggleRef.current = onToggle; const dragScale = useMemo(() => translateX.interpolate({ @@ -362,9 +365,17 @@ function SwipeableFlightCardComponent({ ? Math.max(g.dx * SWIPE_DRAG_RESISTANCE, -SWIPE_MAX_TRANSLATE) : g.dx * 0.08; translateX.setValue(nextTranslate); + + 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) => { if (g.dx < -SWIPE_THRESHOLD || g.vx < -SWIPE_TRIGGER_VELOCITY) { + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); Animated.timing(translateX, { toValue: -SWIPE_MAX_TRANSLATE, duration: 170, @@ -373,9 +384,11 @@ function SwipeableFlightCardComponent({ }).start(() => { onToggleRef.current(); animateBack(); + hasTriggeredHaptic.current = false; }); } else { animateBack(g.vx); + hasTriggeredHaptic.current = false; } }, onPanResponderTerminate: () => { @@ -385,7 +398,11 @@ function SwipeableFlightCardComponent({ return ( - + {children} @@ -450,6 +467,28 @@ 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 parts = []; + if (isPinned) parts.push(t('flightPinnedLabel')); + parts.push(`${flightNumber}, ${airline}`); + parts.push(activeTab === 'arrivals' ? t('flightFrom') : t('flightTo')); + parts.push(originDest); + parts.push(time); + parts.push(statusText); + return parts.join('. '); + }, [isPinned, flightNumber, airline, activeTab, originDest, time, statusText, t]); + + const accessibilityActions = useMemo(() => [ + { name: isPinned ? 'unpin' : 'pin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') }, + ], [isPinned, t]); + + const onAccessibilityAction = useCallback((event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'pin' || event.nativeEvent.actionName === 'unpin') { + if (isPinned) onUnpin(); + else onPin(item); + } + }, [isPinned, onPin, onUnpin, item]); + const normFn = normalizeFlightNumber(flightNumber); const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase(); const normFnStripped = normalizeForMatching(normFn); @@ -554,13 +593,18 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, isPinned ? onUnpin() : onPin(item)} + accessibilityLabel={accessibilityLabel} + accessibilityActions={accessibilityActions} + onAccessibilityAction={onAccessibilityAction} > { openFlightradar24Flight(flightNumber).catch(() => {}); }} accessibilityRole="link" - accessibilityLabel={`Apri ${flightNumber} su Flightradar24`} + importantForAccessibility="no-hide-descendants" + accessibilityLabel={`${flightNumber} — ${t('flightOpenExternal')}`} + accessibilityHint={t('flightOpenExternal')} > {isPinned && {t('flightPinned')}} {/* Header */}