From 9a917cf70ef61705c58d94c246e95a38492b7ad4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:50:08 +0000 Subject: [PATCH] Refactor FlightScreen by extracting FlightCard component Extracted the large `renderFlight` inline component and related helper views (`LogoPill`, `SwipeableFlightCard`) into a new dedicated `FlightCard` component inside `src/components/FlightCard.tsx`. This significantly reduces the size and complexity of `FlightScreen.tsx`, making the file much easier to read and maintain. No functionality or behavior was changed during the extraction. Co-authored-by: TargetMisser <52361977+TargetMisser@users.noreply.github.com> --- package-lock.json | 51 +++--- src/components/FlightCard.tsx | 299 ++++++++++++++++++++++++++++++++++ src/screens/FlightScreen.tsx | 293 ++------------------------------- 3 files changed, 337 insertions(+), 306 deletions(-) create mode 100644 src/components/FlightCard.tsx diff --git a/package-lock.json b/package-lock.json index a3bbe99..9b54474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@react-native-async-storage/async-storage": "2.2.0", - "@react-native-picker/picker": "2.11.1", + "@react-native-picker/picker": "2.11.4", "@types/tesseract.js": "^0.0.2", "expo": "~54.0.0", "expo-blur": "~15.0.8", @@ -23,18 +23,19 @@ "expo-linear-gradient": "~15.0.8", "expo-location": "~19.0.8", "expo-notifications": "~0.32.16", + "expo-secure-store": "~15.0.5", "expo-status-bar": "~3.0.9", "react": "19.1.0", "react-native": "0.81.5", "react-native-android-widget": "^0.20.1", "react-native-calendars": "^1.1314.0", - "react-native-webview": "13.15.0", + "react-native-webview": "13.16.1", "tesseract.js": "^7.0.0" }, "devDependencies": { "@react-native-community/cli": "^20.1.3", "@types/react": "~19.1.10", - "pdfjs-dist": "^5.5.207", + "pdfjs-dist": "^5.6.205", "typescript": "~5.9.2" } }, @@ -80,7 +81,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1492,7 +1492,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2431,7 +2430,6 @@ "integrity": "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native-community/cli-clean": "20.1.3", "@react-native-community/cli-config": "20.1.3", @@ -2996,9 +2994,9 @@ } }, "node_modules/@react-native-picker/picker": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.1.tgz", - "integrity": "sha512-ThklnkK4fV3yynnIIRBkxxjxR4IFbdMNJVF6tlLdOJ/zEFUEFUEdXY0KmH0iYzMwY8W4/InWsLiA7AkpAbnexA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.11.4.tgz", + "integrity": "sha512-Kf8h1AMnBo54b1fdiVylP2P/iFcZqzpMYcglC28EEFB1DEnOjsNr6Ucqc+3R9e91vHxEDnhZFbYDmAe79P2gjA==", "license": "MIT", "workspaces": [ "example" @@ -3399,7 +3397,6 @@ "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4109,7 +4106,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5014,7 +5010,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", @@ -5130,7 +5125,6 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "license": "MIT", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -5239,6 +5233,15 @@ "react-native": "*" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", @@ -8412,16 +8415,16 @@ } }, "node_modules/pdfjs-dist": { - "version": "5.5.207", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", - "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", + "version": "5.6.205", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz", + "integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==", "dev": true, "license": "Apache-2.0", "engines": { "node": ">=20.19.0 || >=22.13.0 || >=24" }, "optionalDependencies": { - "@napi-rs/canvas": "^0.1.95", + "@napi-rs/canvas": "^0.1.96", "node-readable-to-web-readable-stream": "^0.4.2" } }, @@ -8436,7 +8439,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8716,7 +8718,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -8742,7 +8743,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -8849,11 +8849,10 @@ "license": "MIT" }, "node_modules/react-native-webview": { - "version": "13.15.0", - "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz", - "integrity": "sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ==", + "version": "13.16.1", + "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", + "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", "license": "MIT", - "peer": true, "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -8952,7 +8951,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10211,9 +10209,8 @@ "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", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/components/FlightCard.tsx b/src/components/FlightCard.tsx new file mode 100644 index 0000000..ab5694b --- /dev/null +++ b/src/components/FlightCard.tsx @@ -0,0 +1,299 @@ +import React, { useState, useRef, useMemo } from 'react'; +import { View, Text, StyleSheet, Image, Animated, PanResponder } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import { useLanguage } from '../context/LanguageContext'; +import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; +import { normalizeFlightNumber, type StaffMonitorFlight } from '../utils/staffMonitor'; + +function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineName: string; color: string }) { + const [err, setErr] = useState(false); + const uri = `https://pics.avs.io/160/60/${(iataCode || '').toUpperCase()}.png`; + const initials = airlineName.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase(); + if (iataCode && !err) { + return ( + + setErr(true)} /> + + ); + } + return ( + + {initials} + + ); +} + +const SWIPE_THRESHOLD = 80; + +function SwipeableFlightCard({ + children, isPinned, onToggle, +}: { + children: React.ReactNode; + isPinned: boolean; + onToggle: () => void; +}) { + const translateX = useRef(new Animated.Value(0)).current; + const onToggleRef = useRef(onToggle); + onToggleRef.current = onToggle; + + 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); + }, + onPanResponderRelease: (_, g) => { + if (g.dx < -SWIPE_THRESHOLD) { + 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(); + }); + } else { + Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start(); + } + }, + onPanResponderTerminate: () => { + Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); + }, + }), []); + + return ( + + + {children} + + + ); +} + +export interface FlightCardProps { + item: any; + activeTab: 'arrivals' | 'departures'; + userShift: { start: number; end: number } | null | undefined; + pinnedFlightId: string | null; + pinFlight: (item: any) => void; + unpinFlight: () => void; + inboundArrivals: Record; + staffMonitorDeps: StaffMonitorFlight[]; + staffMonitorArrs: StaffMonitorFlight[]; +} + +export function FlightCard({ + item, + activeTab, + userShift, + pinnedFlightId, + pinFlight, + unpinFlight, + inboundArrivals, + staffMonitorDeps, + staffMonitorArrs +}: FlightCardProps) { + const { colors } = useAppTheme(); + const { t, locale } = useLanguage(); + const s = useMemo(() => makeStyles(colors), [colors]); + + const flightNumber = item.flight?.identification?.number?.default || 'N/A'; + const airline = item.flight?.airline?.name || 'Sconosciuta'; + const iataCode = item.flight?.airline?.code?.iata || ''; + const statusText = item.flight?.status?.text || 'Scheduled'; + const raw = item.flight?.status?.generic?.status?.color || 'gray'; + const statusColor = raw === 'green' ? '#10b981' : raw === 'red' ? '#ef4444' : raw === 'yellow' ? '#f59e0b' : '#6b7280'; + const originDest = activeTab === 'arrivals' + ? (item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || 'N/A') + : (item.flight?.airport?.destination?.name || item.flight?.airport?.destination?.code?.iata || 'N/A'); + const ts = activeTab === 'arrivals' ? item.flight?.time?.scheduled?.arrival : item.flight?.time?.scheduled?.departure; + const time = ts ? new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : 'N/A'; + const duringShift = userShift && ts && (() => { + if (activeTab === 'arrivals') return ts >= userShift.start && ts <= userShift.end; + const opsData = getAirlineOps(airline); + const ciOpen = ts - opsData.checkInOpen * 60; + const ciClose = ts - opsData.checkInClose * 60; + const gOpen = ts - opsData.gateOpen * 60; + const gClose = ts - opsData.gateClose * 60; + const ciOverlap = ciOpen <= userShift.end && ciClose >= userShift.start; + const gateOverlap = gOpen <= userShift.end && gClose >= userShift.start; + return ciOverlap || gateOverlap; + })(); + const color = getAirlineColor(airline); + const ops = activeTab === 'departures' && ts ? getAirlineOps(airline) : null; + const fmt = (offsetMin: number) => + ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : ''; + const fmtTs = (tValue: number) => + new Date(tValue * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); + + const reg = item.flight?.aircraft?.registration; + const inboundTs = reg ? inboundArrivals[reg] : undefined; + const gateOpenFromInbound = activeTab === 'departures' && ts && inboundTs ? inboundTs : undefined; + + const flightId = item.flight?.identification?.number?.default || null; + const isPinned = flightId !== null && flightId === pinnedFlightId; + + const normFn = normalizeFlightNumber(flightNumber); + const normalizeForMatching = (str: string) => str.replace(/[\s\-_]/g, '').toUpperCase(); + const normFnStripped = normalizeForMatching(normFn); + const smPool = activeTab === 'departures' ? staffMonitorDeps : staffMonitorArrs; + const smFlight = + smPool.find(sm => sm.flightNumber === normFn) ?? + smPool.find(sm => normalizeForMatching(sm.flightNumber) === normFnStripped); + + if (__DEV__ && !smFlight && smPool.length > 0) { + console.log(`[FlightCard] No staffMonitor match for "${normFn}" (stripped: "${normFnStripped}") in ${activeTab}`); + } + + return ( + isPinned ? unpinFlight() : pinFlight(item)} + > + + {isPinned && {t('flightPinned')}} + + + + + {flightNumber} + {airline} + + + + {time} + {originDest} + + + + {activeTab === 'departures' && ops ? ( + + + + + {t('flightCheckin')} + {fmt(ops.checkInOpen)} – {fmt(ops.checkInClose)} + + + + + + {t('flightGate')} + + {gateOpenFromInbound ? fmtTs(gateOpenFromInbound) : fmt(ops.gateOpen)} – {fmt(ops.gateClose)} + + + + + ) : activeTab === 'arrivals' && ts ? (() => { + const realDep = item.flight?.time?.real?.departure; + const realArr = item.flight?.time?.real?.arrival; + const estArr = item.flight?.time?.estimated?.arrival; + const bestArr = realArr || estArr || ts; + const delayMin = Math.round((bestArr - ts) / 60); + const landed = !!realArr; + const departed = !!realDep; + + const landColor = landed ? '#10B981' + : delayMin > 20 ? '#EF4444' + : delayMin > 5 ? '#F59E0B' + : colors.primary; + const landLabel = landed ? t('flightLanded') : t('flightEstimated'); + + return ( + + + + + {t('flightDeparted')} + + {departed ? fmtTs(realDep) : '--:--'} + + + + + + + {landLabel} + {fmtTs(bestArr)} + + + + ); + })() : ( + {`Da: ${originDest}`} + )} + {activeTab === 'arrivals' && ts ? (() => { + const rArr = item.flight?.time?.real?.arrival; + const eArr = item.flight?.time?.estimated?.arrival; + const bArr = rArr || eArr || ts; + const dMin = Math.round((bArr - ts) / 60); + const isLanded = !!rArr; + const dText = isLanded ? 'Atterrato' : dMin > 0 ? `+${dMin} min` : 'In orario'; + const dColor = isLanded ? '#10B981' : dMin > 20 ? '#EF4444' : dMin > 5 ? '#F59E0B' : '#10B981'; + return ( + + {dText} + + ); + })() : ( + + {statusText} + + )} + + + {smFlight && (smFlight.stand || smFlight.checkin || smFlight.gate || smFlight.belt) && ( + + {smFlight.stand && ( + + + Stand {smFlight.stand} + + )} + {smFlight.checkin && ( + + + {t('flightCheckin')} {smFlight.checkin} + + )} + {smFlight.gate && ( + + + {t('flightGate')} {smFlight.gate} + + )} + {smFlight.belt && ( + + + {t('flightBelt')} {smFlight.belt} + + )} + + )} + + ); +} + +function makeStyles(c: ThemeColors) { + return StyleSheet.create({ + card: { backgroundColor: c.card, borderRadius: 16, marginBottom: 10, overflow: 'hidden', shadowColor: c.primary, shadowOpacity: c.isDark ? 0 : 0.08, shadowRadius: 10, elevation: c.isDark ? 0 : 3, borderWidth: c.isDark ? 1 : 0, borderColor: c.glassBorder }, + cardPinned: { borderWidth: 2, borderColor: '#F59E0B' }, + pinBanner: { backgroundColor: '#F59E0B', paddingVertical: 5, paddingHorizontal: 12 }, + pinBannerText: { color: '#fff', fontWeight: 'bold', fontSize: 11, letterSpacing: 0.5 }, + statusPill: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 20, marginTop: 5 }, + statusText: { fontSize: 10, fontWeight: '700' }, + cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 14 }, + headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + headerFlightNum: { color: '#fff', fontWeight: '900', fontSize: 15, lineHeight: 18 }, + headerAirlineName: { color: 'rgba(255,255,255,0.8)', fontSize: 10 }, + headerTime: { color: '#fff', fontWeight: '900', fontSize: 18, lineHeight: 20, textAlign: 'right' }, + headerDest: { color: 'rgba(255,255,255,0.8)', fontSize: 10, textAlign: 'right' }, + cardBody: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 10, paddingHorizontal: 14, backgroundColor: c.card }, + bodyInfo: { flex: 1, fontSize: 11, color: c.textSub }, + opsRow: { flex: 1, flexDirection: 'row', gap: 8 }, + opsBadge: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: c.primaryLight, borderRadius: 10, paddingHorizontal: 10, paddingVertical: 8 }, + opsLabel: { fontSize: 10, fontWeight: '600', color: c.textSub, letterSpacing: 0.5 }, + opsTime: { fontSize: 13, fontWeight: '800', color: c.primaryDark }, + smFooter: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 14, paddingBottom: 10, backgroundColor: c.card }, + smPill: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: c.primaryLight, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4 }, + smPillText: { fontSize: 11, fontWeight: '700', color: c.primaryDark }, + }); +} diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index 3a28fb0..630ccd7 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -12,8 +12,9 @@ import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; import { useAirport } from '../context/AirportContext'; import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; import { fetchAirportScheduleRaw } from '../utils/fr24api'; -import { fetchStaffMonitorData, normalizeFlightNumber, type StaffMonitorFlight } from '../utils/staffMonitor'; +import { fetchStaffMonitorData, type StaffMonitorFlight } from '../utils/staffMonitor'; import { formatAirportHeader } from '../utils/airportSettings'; +import { FlightCard } from '../components/FlightCard'; import { requestWidgetUpdate } from 'react-native-android-widget'; import { WIDGET_CACHE_KEY } from '../widgets/widgetTaskHandler'; import type { WidgetData, WidgetFlight } from '../widgets/widgetTaskHandler'; @@ -40,67 +41,6 @@ try { Notifications.setNotificationHandler({ }); } catch (e) { if (__DEV__) console.warn('[notifHandler]', e); } -function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineName: string; color: string }) { - const [err, setErr] = useState(false); - const uri = `https://pics.avs.io/160/60/${(iataCode || '').toUpperCase()}.png`; - const initials = airlineName.split(' ').slice(0, 2).map(w => w[0]).join('').toUpperCase(); - if (iataCode && !err) { - return ( - - setErr(true)} /> - - ); - } - return ( - - {initials} - - ); -} - -const SWIPE_THRESHOLD = 80; - -function SwipeableFlightCard({ - children, isPinned, onToggle, -}: { - children: React.ReactNode; - isPinned: boolean; - onToggle: () => void; -}) { - const translateX = useRef(new Animated.Value(0)).current; - const onToggleRef = useRef(onToggle); - onToggleRef.current = onToggle; - - 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); - }, - onPanResponderRelease: (_, g) => { - if (g.dx < -SWIPE_THRESHOLD) { - 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(); - }); - } else { - Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start(); - } - }, - onPanResponderTerminate: () => { - Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); - }, - }), []); - - return ( - - - {children} - - - ); -} - // ─── Helpers notifiche ───────────────────────────────────────────────────────── async function cancelPreviousNotifications() { const raw = await AsyncStorage.getItem(NOTIF_IDS_KEY); @@ -544,197 +484,20 @@ export default function FlightScreen() { })(); const renderFlight = useCallback(({ item }: { item: any }) => { - const flightNumber = item.flight?.identification?.number?.default || 'N/A'; - const airline = item.flight?.airline?.name || 'Sconosciuta'; - const iataCode = item.flight?.airline?.code?.iata || ''; - const statusText = item.flight?.status?.text || 'Scheduled'; - const raw = item.flight?.status?.generic?.status?.color || 'gray'; - const statusColor = raw === 'green' ? '#10b981' : raw === 'red' ? '#ef4444' : raw === 'yellow' ? '#f59e0b' : '#6b7280'; - const originDest = activeTab === 'arrivals' - ? (item.flight?.airport?.origin?.name || item.flight?.airport?.origin?.code?.iata || 'N/A') - : (item.flight?.airport?.destination?.name || item.flight?.airport?.destination?.code?.iata || 'N/A'); - const ts = activeTab === 'arrivals' ? item.flight?.time?.scheduled?.arrival : item.flight?.time?.scheduled?.departure; - const time = ts ? new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : 'N/A'; - const duringShift = userShift && ts && (() => { - if (activeTab === 'arrivals') return ts >= userShift.start && ts <= userShift.end; - // Departures: CI or Gate window overlaps with shift (even 1 min) - const opsData = getAirlineOps(airline); - const ciOpen = ts - opsData.checkInOpen * 60; - const ciClose = ts - opsData.checkInClose * 60; - const gOpen = ts - opsData.gateOpen * 60; - const gClose = ts - opsData.gateClose * 60; - const ciOverlap = ciOpen <= userShift.end && ciClose >= userShift.start; - const gateOverlap = gOpen <= userShift.end && gClose >= userShift.start; - return ciOverlap || gateOverlap; - })(); - const color = getAirlineColor(airline); - // ops is null when ts is falsy — fmt is only called when ops is truthy - const ops = activeTab === 'departures' && ts ? getAirlineOps(airline) : null; - const fmt = (offsetMin: number) => - ts ? new Date((ts - offsetMin * 60) * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : ''; - const fmtTs = (t: number) => - new Date(t * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); - - // Gate open = inbound aircraft arrival time (if available) - const reg = item.flight?.aircraft?.registration; - const inboundTs = reg ? inboundArrivals[reg] : undefined; - const gateOpenFromInbound = activeTab === 'departures' && ts && inboundTs ? inboundTs : undefined; - - const flightId = item.flight?.identification?.number?.default || null; - const isPinned = flightId !== null && flightId === pinnedFlightId; - - const normFn = normalizeFlightNumber(flightNumber); - const normalizeForMatching = (s: string) => s.replace(/[\s\-_]/g, '').toUpperCase(); - const normFnStripped = normalizeForMatching(normFn); - const smPool = activeTab === 'departures' ? staffMonitorDeps : staffMonitorArrs; - const smFlight = - smPool.find(sm => sm.flightNumber === normFn) ?? - smPool.find(sm => normalizeForMatching(sm.flightNumber) === normFnStripped); - if (__DEV__ && !smFlight && smPool.length > 0) { - console.log(`[FlightScreen] No staffMonitor match for "${normFn}" (stripped: "${normFnStripped}") in ${activeTab}`); - } - return ( - isPinned ? unpinFlight() : pinFlight(item)} - > - - {isPinned && {t('flightPinned')}} - {/* Header */} - - - - - {flightNumber} - {airline} - - - - {time} - {originDest} - - - {/* Body */} - - {activeTab === 'departures' && ops ? ( - - - - - {t('flightCheckin')} - {fmt(ops.checkInOpen)} – {fmt(ops.checkInClose)} - - - - - - {t('flightGate')} - - {gateOpenFromInbound ? fmtTs(gateOpenFromInbound) : fmt(ops.gateOpen)} – {fmt(ops.gateClose)} - - - - - ) : activeTab === 'arrivals' && ts ? (() => { - const realDep = item.flight?.time?.real?.departure; - const realArr = item.flight?.time?.real?.arrival; - const estArr = item.flight?.time?.estimated?.arrival; - const bestArr = realArr || estArr || ts; - const delayMin = Math.round((bestArr - ts) / 60); - const landed = !!realArr; - const departed = !!realDep; - - // Color logic for landing badge - const landColor = landed ? '#10B981' - : delayMin > 20 ? '#EF4444' - : delayMin > 5 ? '#F59E0B' - : colors.primary; - const landLabel = landed ? t('flightLanded') : t('flightEstimated'); - - // Delay pill - const delayText = landed ? 'Atterrato' - : delayMin > 0 ? `+${delayMin} min` - : t('flightOnTime'); - const delayColor = landed ? '#10B981' - : delayMin > 20 ? '#EF4444' - : delayMin > 5 ? '#F59E0B' - : '#10B981'; - - return ( - - - - - {t('flightDeparted')} - - {departed ? fmtTs(realDep) : '--:--'} - - - - - - - {landLabel} - {fmtTs(bestArr)} - - - - ); - })() : ( - {`Da: ${originDest}`} - )} - {activeTab === 'arrivals' && ts ? (() => { - const rArr = item.flight?.time?.real?.arrival; - const eArr = item.flight?.time?.estimated?.arrival; - const bArr = rArr || eArr || ts; - const dMin = Math.round((bArr - ts) / 60); - const isLanded = !!rArr; - const dText = isLanded ? 'Atterrato' : dMin > 0 ? `+${dMin} min` : 'In orario'; - const dColor = isLanded ? '#10B981' : dMin > 20 ? '#EF4444' : dMin > 5 ? '#F59E0B' : '#10B981'; - return ( - - {dText} - - ); - })() : ( - - {statusText} - - )} - - - {smFlight && (smFlight.stand || smFlight.checkin || smFlight.gate || smFlight.belt) && ( - - {smFlight.stand && ( - - - Stand {smFlight.stand} - - )} - {smFlight.checkin && ( - - - {t('flightCheckin')} {smFlight.checkin} - - )} - {smFlight.gate && ( - - - {t('flightGate')} {smFlight.gate} - - )} - {smFlight.belt && ( - - - {t('flightBelt')} {smFlight.belt} - - )} - - )} - + ); - }, [activeTab, userShift, s, pinnedFlightId, pinFlight, unpinFlight, inboundArrivals, colors, staffMonitorDeps, staffMonitorArrs]); + }, [activeTab, userShift, pinnedFlightId, pinFlight, unpinFlight, inboundArrivals, staffMonitorDeps, staffMonitorArrs]); return ( @@ -876,31 +639,6 @@ function makeStyles(c: ThemeColors) { segBtnActive: { backgroundColor: c.card, borderWidth: 1, borderColor: c.primaryLight }, segBtnText: { fontSize: 12, fontWeight: '500', color: c.textSub }, segBtnTextActive: { color: c.primary, fontWeight: '700' }, - card: { backgroundColor: c.card, borderRadius: 16, marginBottom: 10, overflow: 'hidden', shadowColor: c.primary, shadowOpacity: c.isDark ? 0 : 0.08, shadowRadius: 10, elevation: c.isDark ? 0 : 3, borderWidth: c.isDark ? 1 : 0, borderColor: c.glassBorder }, - cardShift: { borderWidth: 1.5, borderColor: '#F59E0B' }, - shiftBanner: { backgroundColor: '#F59E0B', paddingVertical: 5, paddingHorizontal: 12 }, - shiftBannerText: { color: '#fff', fontWeight: 'bold', fontSize: 11, letterSpacing: 0.5 }, - cardPinned: { borderWidth: 2, borderColor: '#F59E0B' }, - pinBanner: { backgroundColor: '#F59E0B', paddingVertical: 5, paddingHorizontal: 12 }, - pinBannerText: { color: '#fff', fontWeight: 'bold', fontSize: 11, letterSpacing: 0.5 }, - statusPill: { paddingHorizontal: 8, paddingVertical: 3, borderRadius: 20, marginTop: 5 }, - statusText: { fontSize: 10, fontWeight: '700' }, - cardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, paddingHorizontal: 14 }, - headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 }, - headerFlightNum: { color: '#fff', fontWeight: '900', fontSize: 15, lineHeight: 18 }, - headerAirlineName: { color: 'rgba(255,255,255,0.8)', fontSize: 10 }, - headerTime: { color: '#fff', fontWeight: '900', fontSize: 18, lineHeight: 20, textAlign: 'right' }, - headerDest: { color: 'rgba(255,255,255,0.8)', fontSize: 10, textAlign: 'right' }, - cardBody: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 10, paddingHorizontal: 14, backgroundColor: c.card }, - bodyInfo: { flex: 1, fontSize: 11, color: c.textSub }, - bodyTime: { fontWeight: '700', color: c.text }, - opsRow: { flex: 1, flexDirection: 'row', gap: 8 }, - opsBadge: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 8, backgroundColor: c.primaryLight, borderRadius: 10, paddingHorizontal: 10, paddingVertical: 8 }, - opsIcon: { fontSize: 16 }, - opsLabel: { fontSize: 10, fontWeight: '600', color: c.textSub, letterSpacing: 0.5 }, - opsTime: { fontSize: 13, fontWeight: '800', color: c.primaryDark }, - pinBtn: { width: 34, height: 34, borderRadius: 17, backgroundColor: 'rgba(255,255,255,0.15)', justifyContent: 'center', alignItems: 'center' }, - pinBtnActive: { backgroundColor: 'rgba(245,158,11,0.25)' }, filterBtn: { width: 42, height: 42, borderRadius: 21, backgroundColor: c.cardSecondary, justifyContent: 'center', alignItems: 'center', marginRight: 8 }, filterBtnActive: { backgroundColor: c.primary, shadowColor: c.primary, shadowOffset: { width: 0, height: 3 }, shadowOpacity: 0.35, shadowRadius: 6, elevation: 5 }, modalOverlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.55)', justifyContent: 'flex-end' }, @@ -911,8 +649,5 @@ function makeStyles(c: ThemeColors) { filterOptionActive: { backgroundColor: c.primaryLight, borderWidth: 1.5, borderColor: c.primaryLight }, filterOptionText: { fontSize: 15, fontWeight: '600', color: c.text }, filterOptionSub: { fontSize: 12, color: c.textSub, marginTop: 2 }, - smFooter: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, paddingHorizontal: 14, paddingBottom: 10, backgroundColor: c.card }, - smPill: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: c.primaryLight, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4 }, - smPillText: { fontSize: 11, fontWeight: '700', color: c.primaryDark }, }); }