diff --git a/.jules/palette.md b/.jules/palette.md new file mode 100644 index 0000000..f616aa5 --- /dev/null +++ b/.jules/palette.md @@ -0,0 +1,3 @@ +## 2025-05-15 - Flight Card Haptics and Accessibility +**Learning:** Adding haptic feedback (selection for threshold crossing and success notification for action completion) significantly improves the feel of swipe gestures in React Native. For accessibility, non-pressable interactive cards should use `accessibilityActions` and `onAccessibilityAction` to provide screen reader users with the same functionality as swipe gestures. +**Action:** Use `useRef` to track haptic triggers in continuous gestures and always provide alternative accessibility actions for swipe-based features. 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/package.json b/package.json index 3f8b1c4..4a99dcf 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,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" }, diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 8ccd1e2..3924059 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -106,6 +106,8 @@ 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', + flightAccessibilityPinHint: 'Pinnare volo', flightAccessibilityUnpinHint: 'Rimuovere pin', + flightFrom: 'da', flightTo: 'a', // Phonebook phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi', contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti', @@ -264,6 +266,8 @@ 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', + flightAccessibilityPinHint: 'Pin flight', flightAccessibilityUnpinHint: '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..f010f9f 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,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) { 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,6 +99,7 @@ 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(); @@ -94,7 +108,11 @@ function SwipeableFlightCardComponent({ return ( - + {children} @@ -594,6 +612,20 @@ export default function FlightScreen() { const flightId = item.flight?.identification?.number?.default || null; const isPinned = flightId !== null && flightId === pinnedFlightId; + // Accessibility strings + const pinActionLabel = isPinned ? t('flightAccessibilityUnpinHint') : t('flightAccessibilityPinHint'); + const directionLabel = activeTab === 'arrivals' ? t('homeArrival') : t('homeDeparture'); + const pinnedPrefix = isPinned ? `${t('homePinned')}, ` : ''; + const connector = activeTab === 'arrivals' ? t('flightFrom') : t('flightTo'); + const accessibilityLabel = `${pinnedPrefix}${flightNumber}, ${airline}, ${directionLabel} ${connector} ${originDest}, ${time}, ${statusText}`; + + const handleAccessibilityAction = (event: AccessibilityActionEvent) => { + if (event.nativeEvent.actionName === 'togglePin') { + if (isPinned) unpinFlight(); + else pinFlight(item); + } + }; + const normFn = normalizeFlightNumber(flightNumber); const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase(); const normFnStripped = normalizeForMatching(normFn); @@ -609,6 +641,10 @@ export default function FlightScreen() { isPinned ? unpinFlight() : pinFlight(item)} + accessible + accessibilityLabel={accessibilityLabel} + accessibilityActions={[{ name: 'togglePin', label: pinActionLabel }]} + onAccessibilityAction={handleAccessibilityAction} > {isPinned && {t('flightPinned')}} diff --git a/web_output.log b/web_output.log new file mode 100644 index 0000000..a393dc0 --- /dev/null +++ b/web_output.log @@ -0,0 +1,20 @@ + +> flightworkapp@1.1.0 web /app +> expo start --web + +Starting project at /app +Starting Metro Bundler +The following packages should be updated for best compatibility with the installed expo version: + @react-native-picker/picker@2.11.4 - expected version: 2.11.1 + react-native-webview@13.16.1 - expected version: 13.15.0 +Your project may not work correctly until you install the expected versions of the packages. +Waiting on http://localhost:8081 +Logs for your project will appear below. +Web ./index.ts ░░░░░░░░░░░░░░░░ 0.0% (0/1) +Web ./index.ts ▓▓▓▓▓▓▓░░░░░░░░░ 45.8% (107/188) +Web ./index.ts ▓▓▓▓▓▓▓▓▓▓░░░░░░ 63.4% (393/497) +Web Bundled 9673ms index.ts (664 modules) + LOG [web] Logs will appear in the browser console +Web ./index.ts ░░░░░░░░░░░░░░░░ 0.0% (0/1) +Web Bundled 39ms index.ts (1 module) + LOG [web] Logs will appear in the browser console