diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..a8a4a4f --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2025-04-19 - Accessible Swipe Actions +**Learning:** Gesture-based interactions (like swiping to pin) are inaccessible to screen reader users. +**Action:** Always implement `accessibilityActions` and `onAccessibilityAction` on swipeable or complex gesture-based components to provide an alternative way to trigger those actions. diff --git a/package-lock.json b/package-lock.json index 9503e29..cd6761c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,9 +26,11 @@ "expo-secure-store": "~15.0.5", "expo-status-bar": "~3.0.9", "react": "19.1.0", + "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "^0.20.1", "react-native-calendars": "^1.1314.0", + "react-native-web": "^0.21.0", "react-native-webview": "13.16.1", "tesseract.js": "^7.0.0" }, @@ -4574,6 +4576,15 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4588,6 +4599,15 @@ "node": ">= 8" } }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5625,6 +5645,36 @@ "bser": "2.1.1" } }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -6128,6 +6178,12 @@ "node": ">=10.17.0" } }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -6254,6 +6310,15 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "license": "ISC" }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, "node_modules/invariant": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", @@ -8515,6 +8580,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -8732,6 +8803,18 @@ "ws": "^7" } }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", @@ -8848,6 +8931,38 @@ "integrity": "sha512-Ns7Bn9H/Tyw278+5SQx9oAblDZ7JixyzeOczcBK8dipQk2pD7Djkcfnf1nB/8RErAmMLL9iXgW0QHqiII8AhKw==", "license": "MIT" }, + "node_modules/react-native-web": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", + "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, "node_modules/react-native-webview": { "version": "13.16.1", "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", @@ -9439,6 +9554,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -9843,6 +9964,12 @@ "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", "license": "MIT" }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -10209,7 +10336,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -10219,6 +10346,32 @@ "node": ">=14.17" } }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "6.24.1", "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 8ccd1e2..d4d320a 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -106,6 +106,11 @@ 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', + flightAccessibilityPinHint: 'Scorri a sinistra o usa le azioni per pinnare il volo', + flightAccessibilityUnpinHint: 'Scorri a sinistra o usa le azioni per rimuovere il pin', + flightFrom: 'Da', flightTo: 'Per', // Phonebook phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi', contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti', @@ -264,6 +269,11 @@ 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: 'Swipe left or use actions to pin flight', + flightAccessibilityUnpinHint: 'Swipe left or use actions to unpin flight', + 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..dc369d3 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, } 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,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.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + 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); 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(); @@ -88,13 +102,18 @@ function SwipeableFlightCardComponent({ } }, onPanResponderTerminate: () => { + hasTriggeredHaptic.current = false; Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); }, }), []); return ( - + {children} @@ -594,6 +613,22 @@ export default function FlightScreen() { const flightId = item.flight?.identification?.number?.default || null; const isPinned = flightId !== null && flightId === pinnedFlightId; + const accessLabel = [ + isPinned ? t('flightPinnedLabel') : '', + flightNumber, + airline, + activeTab === 'arrivals' ? t('flightFrom') : t('flightTo'), + originDest, + time, + statusText, + ].filter(Boolean).join(', '); + + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'togglePin') { + isPinned ? unpinFlight() : pinFlight(item); + } + }; + const normFn = normalizeFlightNumber(flightNumber); const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase(); const normFnStripped = normalizeForMatching(normFn); @@ -609,6 +644,11 @@ export default function FlightScreen() { isPinned ? unpinFlight() : pinFlight(item)} + accessible + accessibilityLabel={accessLabel} + accessibilityHint={isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint')} + accessibilityActions={[{ name: 'togglePin', label: isPinned ? t('flightAccessibilityUnpin') : t('flightAccessibilityPin') }]} + onAccessibilityAction={handleAccessibilityAction} > {isPinned && {t('flightPinned')}}