diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml
index cf3ba20..0171426 100644
--- a/.github/workflows/build-release.yml
+++ b/.github/workflows/build-release.yml
@@ -134,6 +134,28 @@ jobs:
ANDROID_HOME: ${{ env.ANDROID_SDK_ROOT }}
GRADLE_OPTS: "-Xmx4g -XX:MaxMetaspaceSize=512m"
+ - name: Validate APK release metadata
+ run: |
+ APK=$(find android/app/build/outputs/apk/release -name "*.apk" -type f | head -1)
+ BADGING=$("$ANDROID_HOME/build-tools/37.0.0/aapt" dump badging "$APK")
+ APK_VERSION_NAME=$(echo "$BADGING" | sed -n "s/.*versionName='\([^']*\)'.*/\1/p")
+ APK_VERSION_CODE=$(echo "$BADGING" | sed -n "s/.*versionCode='\([^']*\)'.*/\1/p")
+ EXPECTED_TAG="v${APK_VERSION_NAME}"
+
+ echo "APK versionName=$APK_VERSION_NAME"
+ echo "APK versionCode=$APK_VERSION_CODE"
+ echo "Release tag=${{ steps.meta.outputs.tag }}"
+
+ if [[ "${{ steps.meta.outputs.version }}" != "$APK_VERSION_NAME" ]]; then
+ echo "app.json version (${{ steps.meta.outputs.version }}) does not match APK versionName ($APK_VERSION_NAME)"
+ exit 1
+ fi
+
+ if [[ "${{ steps.meta.outputs.tag }}" != "$EXPECTED_TAG" ]]; then
+ echo "Release tag (${{ steps.meta.outputs.tag }}) does not match APK versionName ($APK_VERSION_NAME)"
+ exit 1
+ fi
+
- name: Rename APK
run: |
APK=$(find android/app/build/outputs/apk/release -name "*.apk" -type f | head -1)
diff --git a/README.md b/README.md
index a49eea5..e09194a 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ npm run typecheck
APK files are published in [GitHub Releases](https://github.com/TargetMisser/FlightWorkApp/releases).
-Latest stable: **v2.6.29**
+Latest stable: **v2.6.36**
To install:
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 6d4c392..7195e61 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -105,8 +105,8 @@ android {
applicationId 'com.aerostaffpro.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 38
- versionName "2.6.29"
+ versionCode 42
+ versionName "2.6.36"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
}
diff --git a/app.json b/app.json
index 7f62699..19f3fb9 100644
--- a/app.json
+++ b/app.json
@@ -2,7 +2,7 @@
"expo": {
"name": "AeroStaff Pro",
"slug": "AeroStaffPro",
- "version": "2.6.29",
+ "version": "2.6.36",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
diff --git a/package-lock.json b/package-lock.json
index 933b3eb..972e4fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "aerostaff-pro",
- "version": "2.6.29",
+ "version": "2.6.36",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aerostaff-pro",
- "version": "2.6.29",
+ "version": "2.6.36",
"dependencies": {
"@expo/metro-runtime": "~6.1.2",
"@expo/vector-icons": "^15.0.3",
diff --git a/package.json b/package.json
index ff73029..6338501 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "aerostaff-pro",
- "version": "2.6.29",
+ "version": "2.6.36",
"main": "index.ts",
"scripts": {
"start": "expo start",
diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts
index 26c05cb..798eaa1 100644
--- a/src/i18n/translations.ts
+++ b/src/i18n/translations.ts
@@ -7,7 +7,7 @@ const it = {
overlayNotepad: 'Blocco Note', overlayPhonebook: 'Rubrica', overlayPasswords: 'Password',
overlayManuals: 'Manuali DCS', overlaySettings: 'Impostazioni',
// Common
- cancel: 'Annulla', save: 'Salva', delete: 'Elimina', error: 'Errore',
+ cancel: 'Annulla', save: 'Salva', delete: 'Elimina', remove: 'Rimuovi', error: 'Errore',
confirm: 'Conferma', ok: 'OK', add: 'Aggiungi',
profileTitle: 'Profili aeroporto',
profileSubtitle: 'Salva aeroporto e compagnie diverse, poi cambia profilo con un tap.',
@@ -42,6 +42,17 @@ const it = {
sectionAirport: 'AEROPORTO',
airportBase: 'Aeroporto base', airportLoading: 'Caricamento aeroporto...',
airportAirlines: 'Compagnie monitorate', airportAirlinesSub: 'Wizz, easyJet, Ryanair…',
+ sectionFlightData: 'DATI VOLI',
+ airLabsKey: 'AirLabs sperimentale',
+ airLabsKeyNotConfigured: 'Non configurato · usa StaffMonitor come fallback',
+ airLabsKeyDevice: 'Configurato sul dispositivo',
+ airLabsKeyBuild: 'Configurato nella build',
+ airLabsModalTitle: 'Chiave API AirLabs',
+ airLabsModalCopy: 'Incolla una chiave AirLabs per provarlo come fonte voli. Resta nel SecureStore del telefono; se non va, StaffMonitor resta il fallback.',
+ airLabsModalLabel: 'API key',
+ airLabsKeySavedTitle: 'AirLabs configurato',
+ airLabsKeySavedMsg: 'Aggiorna la tab Voli per provare AirLabs come fonte dati.',
+ airLabsKeyErrorMsg: 'Non sono riuscito a salvare la chiave AirLabs.',
sectionNotifications: 'NOTIFICHE',
notifFlights: 'Notifiche voli', notifFlightsSub: 'Gestito nella tab Voli',
notifReminder: 'Promemoria turno', notifReminderSub: 'Prossimamente',
@@ -118,6 +129,8 @@ const it = {
flightTitle: 'Voli in tempo reale', flightArrivals: 'Arrivi', flightDepartures: 'Partenze',
flightToday: 'Oggi', flightTomorrow: 'Domani',
flightNoFlights: 'Nessun volo per questo giorno.',
+ flightDataSource: 'Fonte voli',
+ flightProviderDiagnosticsTitle: 'Diagnostica voli',
flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Nastro', flightDeparted: 'Partito',
flightLanded: 'Atterrato', flightEstimated: 'Stimato', flightOnTime: 'In orario',
flightPinned: 'PINNATO', flightPinnedLabel: 'Pinnato',
@@ -206,7 +219,7 @@ const en: typeof it = {
overlayNotepad: 'Notepad', overlayPhonebook: 'Phonebook', overlayPasswords: 'Password',
overlayManuals: 'DCS Manuals', overlaySettings: 'Settings',
// Common
- cancel: 'Cancel', save: 'Save', delete: 'Delete', error: 'Error',
+ cancel: 'Cancel', save: 'Save', delete: 'Delete', remove: 'Remove', error: 'Error',
confirm: 'Confirm', ok: 'OK', add: 'Add',
profileTitle: 'Airport profiles',
profileSubtitle: 'Save different airports and airline sets, then switch profiles with one tap.',
@@ -241,6 +254,17 @@ const en: typeof it = {
sectionAirport: 'AIRPORT',
airportBase: 'Home airport', airportLoading: 'Loading airport...',
airportAirlines: 'Monitored airlines', airportAirlinesSub: 'Wizz, easyJet, Ryanair…',
+ sectionFlightData: 'FLIGHT DATA',
+ airLabsKey: 'Experimental AirLabs',
+ airLabsKeyNotConfigured: 'Not configured · using StaffMonitor fallback',
+ airLabsKeyDevice: 'Configured on device',
+ airLabsKeyBuild: 'Configured in build',
+ airLabsModalTitle: 'AirLabs API key',
+ airLabsModalCopy: 'Paste an AirLabs key to test it as a flight source. It stays in the phone SecureStore; if it fails, StaffMonitor remains the fallback.',
+ airLabsModalLabel: 'API key',
+ airLabsKeySavedTitle: 'AirLabs configured',
+ airLabsKeySavedMsg: 'Refresh the Flights tab to test AirLabs as data source.',
+ airLabsKeyErrorMsg: 'Could not save the AirLabs key.',
sectionNotifications: 'NOTIFICATIONS',
notifFlights: 'Flight notifications', notifFlightsSub: 'Managed in Flights tab',
notifReminder: 'Shift reminder', notifReminderSub: 'Coming soon',
@@ -317,6 +341,8 @@ const en: typeof it = {
flightTitle: 'Real-time Flights', flightArrivals: 'Arrivals', flightDepartures: 'Departures',
flightToday: 'Today', flightTomorrow: 'Tomorrow',
flightNoFlights: 'No flights for this day.',
+ flightDataSource: 'Flight source',
+ flightProviderDiagnosticsTitle: 'Flight diagnostics',
flightCheckin: 'Check-in', flightGate: 'Gate', flightStand: 'Stand', flightBelt: 'Belt', flightDeparted: 'Departed',
flightLanded: 'Landed', flightEstimated: 'Estimated', flightOnTime: 'On time',
flightPinned: 'PINNED', flightPinnedLabel: 'Pinned',
diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx
index 09dbe5d..1704098 100644
--- a/src/screens/FlightScreen.tsx
+++ b/src/screens/FlightScreen.tsx
@@ -12,7 +12,7 @@ import { MaterialIcons } from '@expo/vector-icons';
import { useAppTheme, type ThemeColors } from '../context/ThemeContext';
import { useAirport } from '../context/AirportContext';
import { getAirlineOps, getAirlineColor, AIRLINE_COLORS, AIRLINE_DISPLAY_NAMES } from '../utils/airlineOps';
-import { fetchAirportScheduleRaw } from '../utils/fr24api';
+import { fetchAirportScheduleRaw, type FlightScheduleProviderStatus } from '../utils/fr24api';
import { fetchStaffMonitorData, normalizeFlightNumber, type StaffMonitorFlight } from '../utils/staffMonitor';
import { formatAirportHeader, getAirportAirlines, getStoredAirportAirlines } from '../utils/airportSettings';
import { requestWidgetUpdate } from 'react-native-android-widget';
@@ -36,6 +36,11 @@ const FLIGHTS_RETENTION_SECONDS = 60 * 60;
const MIN_NOTIF_MINUTES = 1;
const MAX_NOTIF_MINUTES = 90;
type FlightAlertTone = 'success' | 'warning' | 'info';
+type FlightDataSourceState = {
+ sourceLabel: string;
+ fetchedAt: number;
+ diagnostics: FlightScheduleProviderStatus[];
+};
type FlightNotificationSettings = {
onlyTrackedAirlines: boolean;
@@ -57,6 +62,39 @@ const DEFAULT_NOTIFICATION_SETTINGS: FlightNotificationSettings = {
departureLeadMinutes: 10,
};
+function formatFlightProviderDiagnostics(
+ source: FlightDataSourceState | null,
+ locale: string,
+): string {
+ if (!source) return '';
+
+ const fetchedLabel = new Date(source.fetchedAt).toLocaleTimeString(locale, {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ const isItalian = locale.startsWith('it');
+ const lines = [
+ `${isItalian ? 'Fonte attiva' : 'Active source'}: ${source.sourceLabel}`,
+ `${isItalian ? 'Aggiornato' : 'Updated'}: ${fetchedLabel}`,
+ ];
+
+ for (const item of source.diagnostics) {
+ const status = item.status === 'success'
+ ? 'OK'
+ : item.status === 'skipped'
+ ? (isItalian ? 'saltato' : 'skipped')
+ : (isItalian ? 'errore' : 'error');
+ const counts = item.status === 'success'
+ ? ` · A:${item.arrivals ?? 0} D:${item.departures ?? 0}`
+ : '';
+ const timing = typeof item.durationMs === 'number' ? ` · ${item.durationMs}ms` : '';
+ const message = item.message ? ` · ${item.message}` : '';
+ lines.push(`${item.label}: ${status}${counts}${timing}${message}`);
+ }
+
+ return lines.join('\n');
+}
+
function normalizeAirlineKey(value: unknown): string {
return typeof value === 'string'
? value.trim().toLowerCase().replace(/\s+/g, ' ')
@@ -126,7 +164,9 @@ function mergeFlights(cached: any[], fresh: any[], tsField: string): any[] {
function pruneExpiredFlights(items: any[], tsField: string, nowSeconds = Date.now() / 1000): any[] {
const cutoff = nowSeconds - FLIGHTS_RETENTION_SECONDS;
return items.filter(item => {
- const ts = item.flight?.time?.scheduled?.[tsField];
+ const ts = item.flight?.time?.real?.[tsField]
+ || item.flight?.time?.estimated?.[tsField]
+ || item.flight?.time?.scheduled?.[tsField];
if (!ts) return true;
return ts >= cutoff;
});
@@ -430,6 +470,15 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin,
const smFlight =
smPool.find(sm => sm.flightNumber === normFn) ??
smPool.find(sm => normalizeForMatching(sm.flightNumber) === normFnStripped);
+ const operational = item.flight?._operational ?? {};
+ const terminalGate = (terminal?: string, gate?: string) => {
+ if (terminal && gate) return `${terminal}/${gate}`;
+ return gate ?? terminal ?? '—';
+ };
+ const standLabel = smFlight?.stand ?? operational.stand ?? '—';
+ const checkinLabel = smFlight?.checkin ?? operational.checkin ?? '—';
+ const gateLabel = smFlight?.gate ?? terminalGate(operational.departureTerminal, operational.departureGate);
+ const beltLabel = smFlight?.belt ?? operational.belt ?? '—';
const arrivalProgress = activeTab === 'arrivals' && ts ? (() => {
const scheduledDep = item.flight?.time?.scheduled?.departure;
@@ -661,23 +710,23 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin,
- Stand {smFlight?.stand ?? '—'}
+ Stand {standLabel}
{activeTab === 'departures' ? (
<>
- {t('flightCheckin')} {smFlight?.checkin ?? '—'}
+ {t('flightCheckin')} {checkinLabel}
- {t('flightGate')} {smFlight?.gate ?? '—'}
+ {t('flightGate')} {gateLabel}
>
) : (
- {t('flightBelt')} {smFlight?.belt ?? '—'}
+ {t('flightBelt')} {beltLabel}
)}
@@ -913,6 +962,7 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre
const [staffMonitorDeps, setStaffMonitorDeps] = useState([]);
const [staffMonitorArrs, setStaffMonitorArrs] = useState([]);
const [notifSettings, setNotifSettings] = useState(DEFAULT_NOTIFICATION_SETTINGS);
+ const [flightDataSource, setFlightDataSource] = useState(null);
const lastOpenNotifSettingsSignalRef = useRef(openNotifSettingsSignal);
const applySelectedAirlines = useCallback((next: string[]) => {
setSelectedAirlines(next);
@@ -961,6 +1011,13 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre
if (cache.date === today) {
setAllArrivalsFull(cache.arrivals ?? []);
setAllDeparturesFull(cache.departures ?? []);
+ if (cache.sourceLabel && cache.fetchedAt) {
+ setFlightDataSource({
+ sourceLabel: cache.sourceLabel,
+ fetchedAt: cache.fetchedAt,
+ diagnostics: Array.isArray(cache.providerDiagnostics) ? cache.providerDiagnostics : [],
+ });
+ }
}
} catch {}
});
@@ -1016,6 +1073,9 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre
allDepartures,
departures: fetchedDepartures,
arrivals: fetchedArrivals,
+ sourceLabel,
+ providerDiagnostics,
+ fetchedAt,
} = await fetchAirportScheduleRaw(airportCode);
const nextAirportAirlines = getAirportAirlines(airportCode);
setAirportAirlines(nextAirportAirlines);
@@ -1047,9 +1107,23 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre
} catch {}
const mergedArrs = pruneExpiredFlights(mergeFlights(cachedArrs, allArrivals, 'arrival'), 'arrival');
const mergedDeps = pruneExpiredFlights(mergeFlights(cachedDeps, allDepartures, 'departure'), 'departure');
+ const sourceState: FlightDataSourceState = {
+ sourceLabel: sourceLabel ?? 'Sconosciuta',
+ fetchedAt: fetchedAt ?? Date.now(),
+ diagnostics: providerDiagnostics ?? [],
+ };
setAllArrivalsFull(mergedArrs);
setAllDeparturesFull(mergedDeps);
- AsyncStorage.setItem(FLIGHTS_CACHE_KEY, JSON.stringify({ arrivals: mergedArrs, departures: mergedDeps })).catch(() => {});
+ setFlightDataSource(sourceState);
+ AsyncStorage.setItem(FLIGHTS_CACHE_KEY, JSON.stringify({
+ date: new Date().toISOString().split('T')[0],
+ airportCode,
+ arrivals: mergedArrs,
+ departures: mergedDeps,
+ sourceLabel: sourceState.sourceLabel,
+ fetchedAt: sourceState.fetchedAt,
+ providerDiagnostics: sourceState.diagnostics,
+ })).catch(() => {});
// Build inbound arrival map: registration → best known arrival timestamp
const inboundMap: Record = {};
@@ -1540,6 +1614,24 @@ export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScre
+ {flightDataSource && (
+ setNotifDialog({
+ title: t('flightProviderDiagnosticsTitle'),
+ message: formatFlightProviderDiagnostics(flightDataSource, locale),
+ tone: 'info',
+ })}
+ >
+
+
+ {t('flightDataSource')}: {flightDataSource.sourceLabel}
+
+
+
+ )}
+
{loading ? (
@@ -1840,6 +1932,8 @@ function makeStyles(c: ThemeColors) {
pageTitle: { fontSize: 22, fontWeight: 'bold', color: c.primaryDark },
pageSub: { fontSize: 13, color: c.textSub, marginTop: 2 },
controlsRow: { flexDirection: 'row', gap: 8, padding: 12, backgroundColor: c.card, borderBottomWidth: 1, borderBottomColor: c.border },
+ sourceBadge: { flexDirection: 'row', alignItems: 'center', gap: 6, alignSelf: 'flex-start', marginTop: 10, marginHorizontal: 16, paddingHorizontal: 10, paddingVertical: 7, borderRadius: 999, backgroundColor: c.primaryLight, borderWidth: 1, borderColor: c.glassBorder },
+ sourceBadgeText: { fontSize: 11, fontWeight: '800', color: c.primaryDark },
segment: { flex: 1, flexDirection: 'row', backgroundColor: c.bg, borderRadius: 8, padding: 3 },
segBtn: { flex: 1, paddingVertical: 7, alignItems: 'center', borderRadius: 6 },
segBtnActive: { backgroundColor: c.card, borderWidth: 1, borderColor: c.primaryLight },
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index b4c995c..b91a10a 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -23,6 +23,12 @@ import {
} from '../utils/updateChecker';
import UpdateModal from '../components/UpdateModal';
import { exportBackup, importBackup } from '../utils/backupManager';
+import {
+ clearAirLabsApiKey,
+ getAirLabsApiKey,
+ getAirLabsKeyState,
+ saveAirLabsApiKey,
+} from '../utils/flightProviderSettings';
// ─── Tema picker ──────────────────────────────────────────────────────────────
type ThemeOption = {
@@ -192,6 +198,7 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc
const { airport, airportCode, setAirportCode, isLoading: airportLoading } = useAirport();
const { t, lang, setLang, languages } = useLanguage();
const [airportModalOpen, setAirportModalOpen] = useState(false);
+ const [airLabsModalOpen, setAirLabsModalOpen] = useState(false);
const [dialogState, setDialogState] = useState(null);
const translatedOptions = THEME_OPTIONS.map(opt => ({
@@ -200,6 +207,9 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc
sublabel: opt.id === 'light' ? t('themeLightSub') : opt.id === 'dark' ? t('themeDarkSub') : t('themeWeatherSub'),
}));
const [airportInput, setAirportInput] = useState(airportCode);
+ const [airLabsInput, setAirLabsInput] = useState('');
+ const [airLabsStatus, setAirLabsStatus] = useState(t('airLabsKeyNotConfigured'));
+ const [savingAirLabs, setSavingAirLabs] = useState(false);
const [updateInfo, setUpdateInfo] = useState(null);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
@@ -209,6 +219,21 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc
getCachedUpdateInfo().then(setUpdateInfo);
}, []);
+ const refreshAirLabsStatus = useCallback(async () => {
+ const state = await getAirLabsKeyState();
+ if (!state.configured) {
+ setAirLabsStatus(t('airLabsKeyNotConfigured'));
+ return;
+ }
+
+ const sourceLabel = state.source === 'device' ? t('airLabsKeyDevice') : t('airLabsKeyBuild');
+ setAirLabsStatus(`${sourceLabel}${state.masked ? ` · ${state.masked}` : ''}`);
+ }, [t]);
+
+ useEffect(() => {
+ refreshAirLabsStatus().catch(() => {});
+ }, [refreshAirLabsStatus]);
+
const showDialog = useCallback((dialog: DialogState) => {
setDialogState(dialog);
}, []);
@@ -314,6 +339,55 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc
setAirportInput(airportCode);
};
+ const openAirLabsModal = async () => {
+ setAirLabsInput(await getAirLabsApiKey() ?? '');
+ setAirLabsModalOpen(true);
+ };
+
+ const closeAirLabsModal = () => {
+ setAirLabsModalOpen(false);
+ setAirLabsInput('');
+ };
+
+ const saveAirLabsKey = async () => {
+ setSavingAirLabs(true);
+ try {
+ await saveAirLabsApiKey(airLabsInput);
+ await refreshAirLabsStatus();
+ closeAirLabsModal();
+ showDialog({
+ title: t('airLabsKeySavedTitle'),
+ message: t('airLabsKeySavedMsg'),
+ tone: 'success',
+ });
+ } catch {
+ showDialog({
+ title: t('error'),
+ message: t('airLabsKeyErrorMsg'),
+ tone: 'error',
+ });
+ } finally {
+ setSavingAirLabs(false);
+ }
+ };
+
+ const removeAirLabsKey = async () => {
+ setSavingAirLabs(true);
+ try {
+ await clearAirLabsApiKey();
+ await refreshAirLabsStatus();
+ closeAirLabsModal();
+ } catch {
+ showDialog({
+ title: t('error'),
+ message: t('airLabsKeyErrorMsg'),
+ tone: 'error',
+ });
+ } finally {
+ setSavingAirLabs(false);
+ }
+ };
+
const saveAirport = async () => {
const normalized = normalizeAirportCode(airportInput);
if (!isValidAirportCode(normalized)) {
@@ -419,6 +493,18 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc
+ {/* ── Sezione Fonti voli ── */}
+ {t('sectionFlightData')}
+
+ { openAirLabsModal().catch(() => {}); }}
+ />
+
+
{/* ── Sezione Notifiche ── */}
{t('sectionNotifications')}
@@ -736,6 +822,59 @@ export default function SettingsScreen({ onOpenFlightNotifications }: SettingsSc
+
+
+
+
+
+ {t('airLabsModalTitle')}
+
+ {t('airLabsModalCopy')}
+
+
+ {t('airLabsModalLabel')}
+
+
+
+
+ {t('remove')}
+
+
+ {savingAirLabs
+ ?
+ : {t('save')}}
+
+
+
+
+
>
);
}
diff --git a/src/utils/flightProviderSettings.ts b/src/utils/flightProviderSettings.ts
new file mode 100644
index 0000000..8733336
--- /dev/null
+++ b/src/utils/flightProviderSettings.ts
@@ -0,0 +1,79 @@
+import * as SecureStore from 'expo-secure-store';
+
+const AIRLABS_API_KEY_SECURE_KEY = 'aerostaff_airlabs_api_key_v1';
+
+declare const process:
+ | {
+ env?: Record;
+ }
+ | undefined;
+
+export type AirLabsKeyState = {
+ configured: boolean;
+ source: 'device' | 'build' | null;
+ masked: string | null;
+};
+
+function sanitizeApiKey(value: string | null | undefined): string {
+ return (value ?? '').trim();
+}
+
+function getBuildAirLabsApiKey(): string {
+ const value = typeof process !== 'undefined'
+ ? process.env?.EXPO_PUBLIC_AIRLABS_API_KEY
+ : undefined;
+ return sanitizeApiKey(value);
+}
+
+export function maskAirLabsApiKey(value: string | null | undefined): string | null {
+ const key = sanitizeApiKey(value);
+ if (!key) return null;
+ if (key.length <= 8) return `${key.slice(0, 2)}••••`;
+ return `${key.slice(0, 4)}••••${key.slice(-4)}`;
+}
+
+export async function getAirLabsApiKey(): Promise {
+ try {
+ const stored = sanitizeApiKey(await SecureStore.getItemAsync(AIRLABS_API_KEY_SECURE_KEY));
+ if (stored) return stored;
+ } catch {}
+
+ const buildKey = getBuildAirLabsApiKey();
+ return buildKey || null;
+}
+
+export async function getAirLabsKeyState(): Promise {
+ try {
+ const stored = sanitizeApiKey(await SecureStore.getItemAsync(AIRLABS_API_KEY_SECURE_KEY));
+ if (stored) {
+ return {
+ configured: true,
+ source: 'device',
+ masked: maskAirLabsApiKey(stored),
+ };
+ }
+ } catch {}
+
+ const buildKey = getBuildAirLabsApiKey();
+ return {
+ configured: Boolean(buildKey),
+ source: buildKey ? 'build' : null,
+ masked: maskAirLabsApiKey(buildKey),
+ };
+}
+
+export async function saveAirLabsApiKey(value: string): Promise {
+ const key = sanitizeApiKey(value);
+ if (!key) {
+ await clearAirLabsApiKey();
+ return;
+ }
+
+ await SecureStore.setItemAsync(AIRLABS_API_KEY_SECURE_KEY, key);
+}
+
+export async function clearAirLabsApiKey(): Promise {
+ try {
+ await SecureStore.deleteItemAsync(AIRLABS_API_KEY_SECURE_KEY);
+ } catch {}
+}
diff --git a/src/utils/flightProviders/airLabsProvider.ts b/src/utils/flightProviders/airLabsProvider.ts
new file mode 100644
index 0000000..22d4246
--- /dev/null
+++ b/src/utils/flightProviders/airLabsProvider.ts
@@ -0,0 +1,251 @@
+import { AIRLINE_DISPLAY_NAMES } from '../airlineOps';
+import type { FlightScheduleProvider } from './types';
+
+const AIRLABS_API_BASE = 'https://airlabs.co/api/v9/schedules';
+const AIRLABS_LIMIT = 50;
+
+type AirLabsScheduleItem = Record;
+
+const AIRLINE_NAME_BY_IATA: Record = {
+ '3O': 'Air Arabia Maroc',
+ '9H': 'Wizz Air Malta',
+ AZ: 'ITA Airways',
+ BA: AIRLINE_DISPLAY_NAMES['british airways'],
+ EI: AIRLINE_DISPLAY_NAMES['aer lingus'],
+ EN: 'Air Dolomiti',
+ EW: 'Eurowings',
+ FR: AIRLINE_DISPLAY_NAMES.ryanair,
+ FZ: AIRLINE_DISPLAY_NAMES.flydubai,
+ G9: 'Air Arabia',
+ HV: AIRLINE_DISPLAY_NAMES.transavia,
+ LH: 'Lufthansa',
+ QY: 'DHL',
+ RR: 'Buzz',
+ SK: AIRLINE_DISPLAY_NAMES.sas,
+ TO: AIRLINE_DISPLAY_NAMES.transavia,
+ U2: AIRLINE_DISPLAY_NAMES.easyjet,
+ V7: AIRLINE_DISPLAY_NAMES.volotea,
+ VY: AIRLINE_DISPLAY_NAMES.vueling,
+ W4: 'Wizz Air Malta',
+ W6: AIRLINE_DISPLAY_NAMES.wizz,
+ W9: 'Wizz Air UK',
+ XZ: 'Aeroitalia',
+};
+
+function buildAirLabsUrl(paramName: 'dep_iata' | 'arr_iata', airportCode: string, apiKey: string): string {
+ const params = [
+ ['api_key', apiKey],
+ [paramName, airportCode],
+ ['limit', String(AIRLABS_LIMIT)],
+ ];
+ return `${AIRLABS_API_BASE}?${params
+ .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
+ .join('&')}`;
+}
+
+function errorMessage(error: unknown): string {
+ if (error instanceof Error && error.message) return error.message;
+ return String(error ?? 'unknown_error');
+}
+
+function parseUnixTimestamp(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
+ return Math.floor(value);
+ }
+ if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
+ return parseUnixTimestamp(Number(value));
+ }
+ return undefined;
+}
+
+function parseAirLabsDateTime(value: unknown, utc = false): number | undefined {
+ if (typeof value !== 'string') return undefined;
+ const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{1,2}):(\d{2})/);
+ if (!match) return undefined;
+
+ const [, y, mo, d, h, mi] = match;
+ const year = Number(y);
+ const month = Number(mo) - 1;
+ const day = Number(d);
+ const hour = Number(h);
+ const minute = Number(mi);
+ const ms = utc
+ ? Date.UTC(year, month, day, hour, minute, 0, 0)
+ : new Date(year, month, day, hour, minute, 0, 0).getTime();
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : undefined;
+}
+
+function readTime(item: AirLabsScheduleItem, localField: string): number | undefined {
+ return parseUnixTimestamp(item[`${localField}_ts`])
+ ?? parseAirLabsDateTime(item[`${localField}_utc`], true)
+ ?? parseAirLabsDateTime(item[localField], false);
+}
+
+function airportEndpoint(code: unknown, icao: unknown) {
+ const iataCode = typeof code === 'string' ? code.toUpperCase() : undefined;
+ const icaoCode = typeof icao === 'string' ? icao.toUpperCase() : undefined;
+ const name = iataCode || icaoCode || 'N/A';
+ return {
+ name,
+ code: {
+ ...(iataCode ? { iata: iataCode } : {}),
+ ...(icaoCode ? { icao: icaoCode } : {}),
+ },
+ };
+}
+
+function normalizeFlightNumber(item: AirLabsScheduleItem): string {
+ const explicit = typeof item.flight_iata === 'string' ? item.flight_iata.trim() : '';
+ if (explicit) return explicit.replace(/\s+/g, '');
+
+ const airline = typeof item.airline_iata === 'string' ? item.airline_iata.trim().toUpperCase() : '';
+ const number = typeof item.flight_number === 'string' || typeof item.flight_number === 'number'
+ ? String(item.flight_number).trim()
+ : '';
+ return `${airline}${number}`.replace(/\s+/g, '') || 'N/A';
+}
+
+function statusColor(statusText: unknown, scheduledTs: number | undefined, bestTs: number | undefined): string {
+ const status = typeof statusText === 'string' ? statusText.toLowerCase() : '';
+ if (/cancel/.test(status)) return 'red';
+ if (/delay/.test(status)) return 'yellow';
+ if (/active|landed|boarding|departed/.test(status)) return 'green';
+ if (scheduledTs && bestTs && bestTs - scheduledTs > 5 * 60) return 'yellow';
+ return 'gray';
+}
+
+function cleanString(value: unknown): string | undefined {
+ if (typeof value !== 'string' && typeof value !== 'number') return undefined;
+ const cleaned = String(value).trim();
+ return cleaned || undefined;
+}
+
+function itemToScheduleItem(
+ item: AirLabsScheduleItem,
+ direction: 'arrivals' | 'departures',
+): any | null {
+ const timePrefix = direction === 'arrivals' ? 'arr' : 'dep';
+ const timeField = direction === 'arrivals' ? 'arrival' : 'departure';
+ const scheduledTs = readTime(item, `${timePrefix}_time`);
+ if (!scheduledTs) return null;
+
+ const estimatedTs = readTime(item, `${timePrefix}_estimated`);
+ const actualTs = readTime(item, `${timePrefix}_actual`);
+ const bestTs = actualTs ?? estimatedTs ?? scheduledTs;
+ const flightNumber = normalizeFlightNumber(item);
+ const airlineIata = cleanString(item.airline_iata)?.toUpperCase();
+ const airlineIcao = cleanString(item.airline_icao)?.toUpperCase();
+ const airlineName = airlineIata ? AIRLINE_NAME_BY_IATA[airlineIata] : undefined;
+ const depAirport = airportEndpoint(item.dep_iata, item.dep_icao);
+ const arrAirport = airportEndpoint(item.arr_iata, item.arr_icao);
+ const status = cleanString(item.status) ?? (estimatedTs && estimatedTs !== scheduledTs ? 'delayed' : 'scheduled');
+
+ return {
+ flight: {
+ identification: {
+ id: `airlabs_${direction}_${flightNumber}_${scheduledTs}`,
+ number: { default: flightNumber },
+ },
+ airline: {
+ name: airlineName ?? (airlineIata ? `Compagnia ${airlineIata}` : 'Sconosciuta'),
+ code: {
+ ...(airlineIata ? { iata: airlineIata } : {}),
+ ...(airlineIcao ? { icao: airlineIcao } : {}),
+ },
+ },
+ aircraft: {
+ model: { code: cleanString(item.aircraft_icao) },
+ },
+ airport: {
+ origin: depAirport,
+ destination: arrAirport,
+ },
+ time: {
+ scheduled: { [timeField]: scheduledTs },
+ estimated: estimatedTs ? { [timeField]: estimatedTs } : {},
+ real: actualTs ? { [timeField]: actualTs } : {},
+ },
+ status: {
+ text: status,
+ generic: { status: { color: statusColor(status, scheduledTs, bestTs) } },
+ },
+ _operational: {
+ departureGate: cleanString(item.dep_gate),
+ departureTerminal: cleanString(item.dep_terminal),
+ arrivalGate: cleanString(item.arr_gate),
+ arrivalTerminal: cleanString(item.arr_terminal),
+ belt: cleanString(item.arr_baggage),
+ },
+ _source: 'airlabs',
+ },
+ };
+}
+
+async function fetchAirLabsDirection(
+ airportCode: string,
+ direction: 'arrivals' | 'departures',
+ apiKey: string,
+ signal?: AbortSignal,
+): Promise {
+ const paramName = direction === 'arrivals' ? 'arr_iata' : 'dep_iata';
+ const res = await fetch(buildAirLabsUrl(paramName, airportCode, apiKey), {
+ headers: { Accept: 'application/json' },
+ signal,
+ });
+ const body = await res.text();
+
+ if (!res.ok) {
+ throw new Error(`AIRLABS_HTTP_${res.status}`);
+ }
+
+ let json: any;
+ try {
+ json = JSON.parse(body);
+ } catch {
+ throw new Error('AIRLABS_INVALID_JSON_RESPONSE');
+ }
+
+ if (json?.error) {
+ const code = json.error.code ? `AIRLABS_${String(json.error.code).toUpperCase()}` : 'AIRLABS_ERROR';
+ throw new Error(`${code}: ${json.error.message ?? 'Unknown AirLabs error'}`);
+ }
+
+ const response = Array.isArray(json) ? json : json?.response;
+ if (!Array.isArray(response)) {
+ throw new Error('AIRLABS_UNEXPECTED_RESPONSE');
+ }
+
+ return response
+ .map(item => itemToScheduleItem(item, direction))
+ .filter((item): item is any => item !== null);
+}
+
+export const airLabsProvider: FlightScheduleProvider = {
+ id: 'airlabs',
+ label: 'AirLabs',
+ supports: ({ airLabsApiKey }) => Boolean(airLabsApiKey),
+ unavailableMessage: () => 'AirLabs API key non configurata',
+ fetch: async ({ airportCode, airLabsApiKey, signal }) => {
+ if (!airLabsApiKey) throw new Error('AIRLABS_API_KEY_MISSING');
+
+ const [departuresResult, arrivalsResult] = await Promise.allSettled([
+ fetchAirLabsDirection(airportCode, 'departures', airLabsApiKey, signal),
+ fetchAirLabsDirection(airportCode, 'arrivals', airLabsApiKey, signal),
+ ]);
+
+ const allDepartures = departuresResult.status === 'fulfilled' ? departuresResult.value : [];
+ const allArrivals = arrivalsResult.status === 'fulfilled' ? arrivalsResult.value : [];
+
+ if (departuresResult.status === 'rejected' && arrivalsResult.status === 'rejected') {
+ throw new Error(
+ `AIRLABS_FAILED D:${errorMessage(departuresResult.reason)} A:${errorMessage(arrivalsResult.reason)}`,
+ );
+ }
+
+ if (allArrivals.length + allDepartures.length === 0) {
+ throw new Error('AIRLABS_EMPTY_SCHEDULE');
+ }
+
+ return { allArrivals, allDepartures };
+ },
+};
diff --git a/src/utils/flightProviders/fr24Provider.ts b/src/utils/flightProviders/fr24Provider.ts
new file mode 100644
index 0000000..7be4807
--- /dev/null
+++ b/src/utils/flightProviders/fr24Provider.ts
@@ -0,0 +1,37 @@
+import { buildFr24ScheduleUrl } from '../airportSettings';
+import type { FlightScheduleProvider } from './types';
+
+export const fr24Provider: FlightScheduleProvider = {
+ id: 'fr24',
+ label: 'FlightRadar24',
+ supports: () => true,
+ fetch: async ({ airportCode, signal }) => {
+ const res = await fetch(buildFr24ScheduleUrl(airportCode), {
+ headers: {
+ 'User-Agent': 'Mozilla/5.0',
+ Accept: 'application/json,text/plain,*/*',
+ },
+ signal,
+ });
+ const body = await res.text();
+
+ if (!res.ok) {
+ throw new Error(`FR24_HTTP_${res.status}`);
+ }
+ if (/^\s* {
+ const diagnostics: FlightScheduleProviderStatus[] = [];
+
+ for (const provider of providers) {
+ if (!provider.supports(context)) {
+ diagnostics.push({
+ provider: provider.id,
+ label: provider.label,
+ status: 'skipped',
+ message: provider.unavailableMessage?.(context) ?? `Unsupported airport ${context.airportCode}`,
+ });
+ continue;
+ }
+
+ const startedAt = Date.now();
+ try {
+ const result = await provider.fetch(context);
+ const durationMs = Date.now() - startedAt;
+ diagnostics.push({
+ provider: provider.id,
+ label: provider.label,
+ status: 'success',
+ durationMs,
+ arrivals: result.allArrivals.length,
+ departures: result.allDepartures.length,
+ });
+
+ return {
+ ...result,
+ source: provider.id,
+ sourceLabel: provider.label,
+ fetchedAt: Date.now(),
+ diagnostics,
+ };
+ } catch (error) {
+ diagnostics.push({
+ provider: provider.id,
+ label: provider.label,
+ status: 'failed',
+ durationMs: Date.now() - startedAt,
+ message: errorMessage(error),
+ });
+ if (__DEV__) console.warn(`[flightProviders] ${provider.id} failed:`, error);
+ }
+ }
+
+ const summary = diagnostics.map(item => `${item.label}: ${item.message ?? item.status}`).join(' | ');
+ throw new Error(`NO_FLIGHT_PROVIDER_AVAILABLE ${summary}`);
+}
diff --git a/src/utils/flightProviders/staffMonitorProvider.ts b/src/utils/flightProviders/staffMonitorProvider.ts
new file mode 100644
index 0000000..9866813
--- /dev/null
+++ b/src/utils/flightProviders/staffMonitorProvider.ts
@@ -0,0 +1,171 @@
+import { AIRLINE_DISPLAY_NAMES } from '../airlineOps';
+import { fetchStaffMonitorData, type StaffMonitorFlight } from '../staffMonitor';
+import type { FlightScheduleProvider } from './types';
+
+const STAFF_MONITOR_AIRPORT = 'PSA';
+
+type AirlineMeta = {
+ name: string;
+ iata: string;
+ prefixes: string[];
+};
+
+const AIRLINE_BY_FLIGHT_PREFIX: AirlineMeta[] = [
+ { name: AIRLINE_DISPLAY_NAMES.ryanair, iata: 'FR', prefixes: ['FR', 'RYR'] },
+ { name: AIRLINE_DISPLAY_NAMES.easyjet, iata: 'U2', prefixes: ['U2', 'EJU', 'EZY'] },
+ { name: AIRLINE_DISPLAY_NAMES.wizz, iata: 'W6', prefixes: ['W6', 'W4', 'W9'] },
+ { name: AIRLINE_DISPLAY_NAMES.volotea, iata: 'V7', prefixes: ['V7'] },
+ { name: AIRLINE_DISPLAY_NAMES.vueling, iata: 'VY', prefixes: ['VY'] },
+ { name: AIRLINE_DISPLAY_NAMES.transavia, iata: 'HV', prefixes: ['HV', 'TO'] },
+ { name: AIRLINE_DISPLAY_NAMES['aer lingus'], iata: 'EI', prefixes: ['EI'] },
+ { name: AIRLINE_DISPLAY_NAMES['british airways'], iata: 'BA', prefixes: ['BA'] },
+ { name: AIRLINE_DISPLAY_NAMES.sas, iata: 'SK', prefixes: ['SK', 'SAS'] },
+ { name: AIRLINE_DISPLAY_NAMES.flydubai, iata: 'FZ', prefixes: ['FZ'] },
+ { name: 'Aeroitalia', iata: 'XZ', prefixes: ['XZ'] },
+ { name: 'Air Arabia Maroc', iata: '3O', prefixes: ['3O'] },
+ { name: 'Air Arabia', iata: 'G9', prefixes: ['G9'] },
+ { name: 'Air Dolomiti', iata: 'EN', prefixes: ['EN'] },
+ { name: 'Buzz', iata: 'RR', prefixes: ['RR'] },
+ { name: 'DHL', iata: 'QY', prefixes: ['QY'] },
+ { name: 'Eurowings', iata: 'EW', prefixes: ['EW'] },
+ { name: 'ITA Airways', iata: 'AZ', prefixes: ['AZ'] },
+ { name: 'Lufthansa', iata: 'LH', prefixes: ['LH'] },
+];
+
+function inferAirline(flightNumber: string): AirlineMeta {
+ const normalized = flightNumber.toUpperCase().replace(/\s+/g, '');
+ for (const airline of AIRLINE_BY_FLIGHT_PREFIX) {
+ if (airline.prefixes.some(prefix => normalized.startsWith(prefix))) {
+ return airline;
+ }
+ }
+
+ const fallbackCode = normalized.match(/^([A-Z0-9]{2,3}?)(?=\d)/)?.[1] ?? normalized.slice(0, 2);
+ return {
+ name: fallbackCode ? `Compagnia ${fallbackCode}` : 'Sconosciuta',
+ iata: fallbackCode,
+ prefixes: [fallbackCode],
+ };
+}
+
+function parseStaffMonitorClock(value?: string, baseDate = new Date()): number | undefined {
+ const match = value?.match(/\b(\d{1,2})[:.](\d{2})\b/);
+ if (!match) return undefined;
+
+ const hours = Number(match[1]);
+ const minutes = Number(match[2]);
+ if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours > 23 || minutes > 59) {
+ return undefined;
+ }
+
+ const date = new Date(baseDate);
+ date.setHours(hours, minutes, 0, 0);
+ return Math.floor(date.getTime() / 1000);
+}
+
+function alignEstimatedTime(scheduledTs: number | undefined, estimatedTs: number | undefined): number | undefined {
+ if (!scheduledTs || !estimatedTs) return estimatedTs;
+ const halfDay = 12 * 60 * 60;
+ if (estimatedTs < scheduledTs - halfDay) return estimatedTs + 24 * 60 * 60;
+ if (estimatedTs > scheduledTs + halfDay) return estimatedTs - 24 * 60 * 60;
+ return estimatedTs;
+}
+
+function statusColor(statusText: string | undefined, scheduledTs: number | undefined, bestTs: number | undefined): string {
+ const status = (statusText ?? '').toLowerCase();
+ if (/cancel|annull/.test(status)) return 'red';
+ if (/ritar|delay/.test(status)) return 'yellow';
+ if (/imbarco|boarding|atterr|landed|decoll|partit|take/.test(status)) return 'green';
+ if (scheduledTs && bestTs && bestTs - scheduledTs > 5 * 60) return 'yellow';
+ return 'gray';
+}
+
+function hasRealEvent(statusText: string | undefined, direction: 'arrivals' | 'departures'): boolean {
+ const status = (statusText ?? '').toLowerCase();
+ return direction === 'arrivals'
+ ? /atterr|landed/.test(status)
+ : /decoll|partit|take/.test(status);
+}
+
+function airportEndpoint(name: string, iata?: string, icao?: string) {
+ return {
+ name,
+ code: {
+ ...(iata ? { iata } : {}),
+ ...(icao ? { icao } : {}),
+ },
+ };
+}
+
+function staffMonitorFlightToScheduleItem(
+ item: StaffMonitorFlight,
+ direction: 'arrivals' | 'departures',
+ airportCode: string,
+ airportName: string,
+ airportIcao: string | undefined,
+ now: Date,
+): any | null {
+ const scheduledTs = parseStaffMonitorClock(item.scheduledTime, now);
+ if (!scheduledTs) return null;
+
+ const estimatedTs = alignEstimatedTime(scheduledTs, parseStaffMonitorClock(item.estimatedTime, now));
+ const effectiveTs = estimatedTs ?? scheduledTs;
+ const timeField = direction === 'arrivals' ? 'arrival' : 'departure';
+ const airline = inferAirline(item.flightNumber);
+ const routeName = item.route ?? 'N/A';
+ const homeAirport = airportEndpoint(airportName, airportCode, airportIcao);
+ const remoteAirport = airportEndpoint(routeName);
+ const realTs = hasRealEvent(item.status, direction) ? effectiveTs : undefined;
+ const statusText = item.status ?? (estimatedTs && estimatedTs !== scheduledTs ? 'Stimato' : 'Scheduled');
+
+ return {
+ flight: {
+ identification: {
+ id: `staffmonitor_${direction}_${item.flightNumber}_${scheduledTs}`,
+ number: { default: item.flightNumber },
+ },
+ airline: {
+ name: airline.name,
+ code: { iata: airline.iata },
+ },
+ aircraft: {
+ registration: item.registration,
+ model: { code: item.aircraftType },
+ },
+ airport: direction === 'arrivals'
+ ? { origin: remoteAirport, destination: homeAirport }
+ : { origin: homeAirport, destination: remoteAirport },
+ time: {
+ scheduled: { [timeField]: scheduledTs },
+ estimated: estimatedTs ? { [timeField]: estimatedTs } : {},
+ real: realTs ? { [timeField]: realTs } : {},
+ },
+ status: {
+ text: statusText,
+ generic: { status: { color: statusColor(item.status, scheduledTs, effectiveTs) } },
+ },
+ _source: 'staffMonitor',
+ },
+ };
+}
+
+export const staffMonitorProvider: FlightScheduleProvider = {
+ id: 'staffMonitor',
+ label: 'StaffMonitor PSA',
+ supports: ({ airportCode }) => airportCode === STAFF_MONITOR_AIRPORT,
+ fetch: async ({ airportCode, airport, now = new Date() }) => {
+ const [departures, arrivals] = await Promise.all([
+ fetchStaffMonitorData('D'),
+ fetchStaffMonitorData('A'),
+ ]);
+
+ return {
+ allArrivals: arrivals
+ .map(item => staffMonitorFlightToScheduleItem(item, 'arrivals', airportCode, airport.name, airport.icao, now))
+ .filter((item): item is any => item !== null),
+ allDepartures: departures
+ .map(item => staffMonitorFlightToScheduleItem(item, 'departures', airportCode, airport.name, airport.icao, now))
+ .filter((item): item is any => item !== null),
+ };
+ },
+};
diff --git a/src/utils/flightProviders/types.ts b/src/utils/flightProviders/types.ts
new file mode 100644
index 0000000..3f2d244
--- /dev/null
+++ b/src/utils/flightProviders/types.ts
@@ -0,0 +1,41 @@
+import type { AirportInfo } from '../airportSettings';
+
+export type FlightScheduleProviderId = 'airlabs' | 'fr24' | 'staffMonitor' | 'cache';
+
+export type FlightScheduleProviderStatus = {
+ provider: FlightScheduleProviderId;
+ label: string;
+ status: 'success' | 'failed' | 'skipped';
+ message?: string;
+ durationMs?: number;
+ arrivals?: number;
+ departures?: number;
+};
+
+export type FlightScheduleProviderContext = {
+ airportCode: string;
+ airport: AirportInfo;
+ airLabsApiKey?: string | null;
+ signal?: AbortSignal;
+ now?: Date;
+};
+
+export type FlightScheduleProviderResult = {
+ allArrivals: any[];
+ allDepartures: any[];
+};
+
+export type FlightSchedulePayload = FlightScheduleProviderResult & {
+ source: FlightScheduleProviderId;
+ sourceLabel: string;
+ fetchedAt: number;
+ diagnostics: FlightScheduleProviderStatus[];
+};
+
+export type FlightScheduleProvider = {
+ id: FlightScheduleProviderId;
+ label: string;
+ supports: (context: FlightScheduleProviderContext) => boolean;
+ unavailableMessage?: (context: FlightScheduleProviderContext) => string;
+ fetch: (context: FlightScheduleProviderContext) => Promise;
+};
diff --git a/src/utils/fr24api.ts b/src/utils/fr24api.ts
index f58d647..918e04e 100644
--- a/src/utils/fr24api.ts
+++ b/src/utils/fr24api.ts
@@ -1,5 +1,5 @@
+import AsyncStorage from '@react-native-async-storage/async-storage';
import {
- buildFr24ScheduleUrl,
getAirportAirlines,
getAirportInfo,
getStoredAirportCode,
@@ -8,23 +8,33 @@ import {
storeDetectedAirportAirlines,
type AirportInfo,
} from './airportSettings';
+import {
+ fetchFlightScheduleFromProviders,
+ type FlightScheduleProviderId,
+ type FlightScheduleProviderStatus,
+} from './flightProviders';
+import { getAirLabsApiKey } from './flightProviderSettings';
const FETCH_TIMEOUT = 10000; // 10 seconds
+const SCHEDULE_CACHE_KEY = 'aerostaff_schedule_provider_cache_v1';
+const SCHEDULE_CACHE_TTL_MS = 30 * 60 * 1000;
+
+export type { FlightScheduleProviderId, FlightScheduleProviderStatus };
export type FR24Schedule = {
arrivals: any[];
departures: any[];
airportCode: string;
airport: AirportInfo;
+ source?: FlightScheduleProviderId;
+ sourceLabel?: string;
+ providerDiagnostics?: FlightScheduleProviderStatus[];
+ fetchedAt?: number;
};
-export type FR24ScheduleRaw = {
+export type FR24ScheduleRaw = FR24Schedule & {
allArrivals: any[];
allDepartures: any[];
- arrivals: any[];
- departures: any[];
- airportCode: string;
- airport: AirportInfo;
};
function filterAirlines(data: any[], allowedList: string[]) {
@@ -42,70 +52,136 @@ async function resolveAirportCode(code?: string): Promise {
return isValidAirportCode(normalized) ? normalized : getStoredAirportCode();
}
-/**
- * Fetch airport schedule from FlightRadar24, filtered by allowed airlines.
- * Includes a 10s timeout to prevent UI blocking.
- */
-export async function fetchAirportSchedule(code?: string): Promise {
+type ScheduleCacheEntry = {
+ airportCode: string;
+ allArrivals: any[];
+ allDepartures: any[];
+ source?: FlightScheduleProviderId;
+ sourceLabel?: string;
+ providerDiagnostics?: FlightScheduleProviderStatus[];
+ fetchedAt: number;
+ savedAt: number;
+};
+
+function errorMessage(error: unknown): string {
+ if (error instanceof Error && error.message) return error.message;
+ return String(error ?? 'unknown_error');
+}
+
+async function loadCachedSchedule(airportCode: string): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(SCHEDULE_CACHE_KEY);
+ if (!raw) return null;
+ const cache = JSON.parse(raw);
+ const entry = cache?.[airportCode] as ScheduleCacheEntry | undefined;
+ if (!entry || Date.now() - entry.savedAt > SCHEDULE_CACHE_TTL_MS) return null;
+ if (!Array.isArray(entry.allArrivals) || !Array.isArray(entry.allDepartures)) return null;
+ return entry;
+ } catch {
+ return null;
+ }
+}
+
+async function saveCachedSchedule(entry: ScheduleCacheEntry): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(SCHEDULE_CACHE_KEY);
+ const cache = raw ? JSON.parse(raw) : {};
+ cache[entry.airportCode] = entry;
+ await AsyncStorage.setItem(SCHEDULE_CACHE_KEY, JSON.stringify(cache));
+ } catch {}
+}
+
+async function fetchScheduleRawData(code?: string): Promise {
+ const airportCode = await resolveAirportCode(code);
+ const airport = getAirportInfo(airportCode);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
+ let payload: Awaited>;
try {
- const airportCode = await resolveAirportCode(code);
- const res = await fetch(buildFr24ScheduleUrl(airportCode), {
- headers: { 'User-Agent': 'Mozilla/5.0' },
+ const airLabsApiKey = await getAirLabsApiKey();
+ payload = await fetchFlightScheduleFromProviders({
+ airportCode,
+ airport,
+ airLabsApiKey,
signal: controller.signal,
});
- const json = await res.json();
-
- const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || [];
- const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || [];
-
- await storeDetectedAirportAirlines(airportCode, allArrivals, allDepartures);
- const airlines = getAirportAirlines(airportCode);
- return {
- arrivals: filterAirlines(allArrivals, airlines),
- departures: filterAirlines(allDepartures, airlines),
+ await saveCachedSchedule({
airportCode,
- airport: getAirportInfo(airportCode),
+ allArrivals: payload.allArrivals,
+ allDepartures: payload.allDepartures,
+ source: payload.source,
+ sourceLabel: payload.sourceLabel,
+ providerDiagnostics: payload.diagnostics,
+ fetchedAt: payload.fetchedAt,
+ savedAt: Date.now(),
+ });
+ } catch (error) {
+ const cached = await loadCachedSchedule(airportCode);
+ if (!cached) throw error;
+
+ payload = {
+ allArrivals: cached.allArrivals,
+ allDepartures: cached.allDepartures,
+ source: cached.source ?? 'cache',
+ sourceLabel: `${cached.sourceLabel ?? 'Cache voli'} (cache)`,
+ fetchedAt: cached.fetchedAt,
+ diagnostics: [
+ ...(cached.providerDiagnostics ?? []),
+ {
+ provider: 'cache',
+ label: 'Cache voli',
+ status: 'success',
+ message: `Fallback cache: ${errorMessage(error)}`,
+ },
+ ],
};
} finally {
clearTimeout(timer);
}
+
+ const { allArrivals, allDepartures } = payload;
+ await storeDetectedAirportAirlines(airportCode, allArrivals, allDepartures);
+ const airlines = getAirportAirlines(airportCode);
+ return {
+ allArrivals,
+ allDepartures,
+ arrivals: filterAirlines(allArrivals, airlines),
+ departures: filterAirlines(allDepartures, airlines),
+ airportCode,
+ airport,
+ source: payload.source,
+ sourceLabel: payload.sourceLabel,
+ providerDiagnostics: payload.diagnostics,
+ fetchedAt: payload.fetchedAt,
+ };
+}
+
+/**
+ * Fetch airport schedule, filtered by allowed airlines.
+ * Uses the provider layer under the hood: configured external providers first,
+ * then airport-specific fallbacks and local cache.
+ */
+export async function fetchAirportSchedule(code?: string): Promise {
+ const raw = await fetchScheduleRawData(code);
+ return {
+ arrivals: raw.arrivals,
+ departures: raw.departures,
+ airportCode: raw.airportCode,
+ airport: raw.airport,
+ source: raw.source,
+ sourceLabel: raw.sourceLabel,
+ providerDiagnostics: raw.providerDiagnostics,
+ fetchedAt: raw.fetchedAt,
+ };
}
/**
- * Fetch raw (unfiltered) schedule — needed when callers also use non-allowed airline data
+ * Fetch raw (unfiltered) schedule - needed when callers also use non-allowed airline data
* (e.g. inbound arrival map by registration).
*/
export async function fetchAirportScheduleRaw(code?: string): Promise {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
-
- try {
- const airportCode = await resolveAirportCode(code);
- const res = await fetch(buildFr24ScheduleUrl(airportCode), {
- headers: { 'User-Agent': 'Mozilla/5.0' },
- signal: controller.signal,
- });
- const json = await res.json();
-
- const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || [];
- const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || [];
-
- await storeDetectedAirportAirlines(airportCode, allArrivals, allDepartures);
- const airlines = getAirportAirlines(airportCode);
- return {
- allArrivals,
- allDepartures,
- arrivals: filterAirlines(allArrivals, airlines),
- departures: filterAirlines(allDepartures, airlines),
- airportCode,
- airport: getAirportInfo(airportCode),
- };
- } finally {
- clearTimeout(timer);
- }
+ return fetchScheduleRawData(code);
}
// Legacy aliases kept to avoid breaking older imports.
diff --git a/src/utils/staffMonitor.ts b/src/utils/staffMonitor.ts
index 4281f7a..948d4fe 100644
--- a/src/utils/staffMonitor.ts
+++ b/src/utils/staffMonitor.ts
@@ -2,6 +2,13 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
export type StaffMonitorFlight = {
flightNumber: string;
+ aircraftType?: string;
+ trafficType?: string;
+ registration?: string;
+ route?: string;
+ scheduledTime?: string;
+ estimatedTime?: string;
+ status?: string;
stand?: string;
checkin?: string;
gate?: string;
@@ -56,6 +63,13 @@ function isPhoneOrJunk(val: string): boolean {
type ColMap = {
flight: number;
+ aircraftType?: number;
+ trafficType?: number;
+ registration?: number;
+ route?: number;
+ scheduled?: number;
+ estimated?: number;
+ status?: number;
stand?: number;
checkin?: number;
gate?: number;
@@ -85,6 +99,13 @@ function detectColumns(headerRow: RawCell[]): ColMap | null {
// For "VOLO / FLIGHT" with colspan=2, logo is first sub-col, flight # is last.
const flightCol = flightH.start + flightH.span - 1;
+ const aircraftTypeH = findPos(n => n.includes('ac type') || n.includes('a/c type') || n.includes('aircraft'));
+ const trafficTypeH = findPos(n => n.includes('tr.type') || n.includes('traffic'));
+ const registrationH = findPos(n => n === 'reg' || n.includes('registr'));
+ const routeH = findPos(n => n.includes('dest') || n.includes('from') || n.includes('to') || n === 'da');
+ const scheduledH = findPos(n => n === 'sched' || n.includes('schedul') || n.includes('programm'));
+ const estimatedH = findPos(n => n === 'exp' || n.includes('estim') || n.includes('previst'));
+ const statusH = findPos(n => n === 'status' || n.includes('stato'));
// Use word-boundary checks: 'stand' as a whole word to avoid matching "addetto stand" / "standby"
const standH = findPos(n => n === 'stand' || /\bstand\b/.test(n) || n.includes('parch') || n.includes('posiz') || n.includes('piazzola') || n === 'park');
const checkinH = findPos(n => n.includes('check') || n === 'c/i' || n === 'ci' || n === 'banco' || n.includes('desk') || n.includes('bancone'));
@@ -93,6 +114,13 @@ function detectColumns(headerRow: RawCell[]): ColMap | null {
const beltH = findPos(n => n.includes('belt') || n.includes('nastro') || n.includes('tapis') || n.includes('baggage') || n.includes('reclam') || n.includes('bggl'));
const map: ColMap = { flight: flightCol };
+ if (aircraftTypeH) map.aircraftType = aircraftTypeH.start;
+ if (trafficTypeH) map.trafficType = trafficTypeH.start;
+ if (registrationH) map.registration = registrationH.start;
+ if (routeH) map.route = routeH.start;
+ if (scheduledH) map.scheduled = scheduledH.start;
+ if (estimatedH) map.estimated = estimatedH.start;
+ if (statusH) map.status = statusH.start;
if (standH) map.stand = standH.start;
if (checkinH) map.checkin = checkinH.start;
if (gateH) map.gate = gateH.start;
@@ -120,6 +148,25 @@ function cell(cells: string[], idx: number | undefined): string | undefined {
return code;
}
+function textCell(cells: string[], idx: number | undefined): string | undefined {
+ if (idx === undefined) return undefined;
+ const raw = cells[idx]?.trim();
+ if (!raw || raw === '/' || raw === '-' || raw === '--') return undefined;
+ return raw
+ .replace(/\s*\|\s*/g, ' / ')
+ .replace(/\s*\/\s*$/g, '')
+ .replace(/\s+/g, ' ')
+ .trim() || undefined;
+}
+
+function timeCell(cells: string[], idx: number | undefined): string | undefined {
+ const raw = textCell(cells, idx);
+ const match = raw?.match(/\b(\d{1,2})[:.](\d{2})\b/);
+ if (!match) return undefined;
+ const hour = match[1].padStart(2, '0');
+ return `${hour}:${match[2]}`;
+}
+
/** Extract flight number from a cell that may contain "FR03747 B738" — take first token only. */
function extractFlightCode(raw: string): string | null {
const token = raw.trim().split(/\s+/)[0];
@@ -153,6 +200,13 @@ function parseSection(sectionHTML: string): StaffMonitorFlight[] {
results.push({
flightNumber,
+ aircraftType: textCell(cells, colMap.aircraftType),
+ trafficType: textCell(cells, colMap.trafficType),
+ registration: textCell(cells, colMap.registration),
+ route: textCell(cells, colMap.route),
+ scheduledTime: timeCell(cells, colMap.scheduled),
+ estimatedTime: timeCell(cells, colMap.estimated),
+ status: textCell(cells, colMap.status),
stand: cell(cells, colMap.stand),
checkin: cell(cells, colMap.checkin),
gate: cell(cells, colMap.gate),
@@ -373,7 +427,7 @@ export async function fetchStaffMonitorData(nature: 'D' | 'A'): Promise `${f.flightNumber} S=${f.stand ?? '-'} CI=${f.checkin ?? '-'} G=${f.gate ?? '-'} B=${f.belt ?? '-'}`).join('\n');
+ : results.slice(0, 5).map(f => `${f.flightNumber} ${f.scheduledTime ?? '--:--'}>${f.estimatedTime ?? '--:--'} ${f.route ?? '-'} S=${f.stand ?? '-'} CI=${f.checkin ?? '-'} G=${f.gate ?? '-'} B=${f.belt ?? '-'}`).join('\n');
if (nature === 'D') _lastDebugFlightsD = summary;
else _lastDebugFlightsA = summary;