diff --git a/App.tsx b/App.tsx index 46ec21b..4798824 100644 --- a/App.tsx +++ b/App.tsx @@ -5,6 +5,7 @@ import { BlurView } from 'expo-blur'; import * as Haptics from 'expo-haptics'; import { MaterialIcons } from '@expo/vector-icons'; import { ThemeProvider, useAppTheme } from './src/context/ThemeContext'; +import { AirportProvider } from './src/context/AirportContext'; import HomeScreen from './src/screens/HomeScreen'; import TraveldocScreen from './src/screens/TraveldocScreen'; import FlightScreen from './src/screens/FlightScreen'; @@ -263,7 +264,9 @@ function AppInner() { export default function App() { return ( - + + + ); } diff --git a/package.json b/package.json index b461b32..3f8b1c4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,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", @@ -25,18 +25,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" }, "private": true diff --git a/src/components/ShiftTimeline.tsx b/src/components/ShiftTimeline.tsx index 441ab8a..902c665 100644 --- a/src/components/ShiftTimeline.tsx +++ b/src/components/ShiftTimeline.tsx @@ -5,7 +5,9 @@ import { } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme } from '../context/ThemeContext'; -import { getAirlineOps, getAirlineColor, ALLOWED_AIRLINES } from '../utils/airlineOps'; +import { useAirport } from '../context/AirportContext'; +import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; +import { fetchAirportScheduleRaw } from '../utils/fr24api'; if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); @@ -53,6 +55,7 @@ function parseFlight(item: any): Flight | null { export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd, inline }: Props) { const { colors } = useAppTheme(); + const { airportCode, isLoading: airportLoading } = useAirport(); const [flights, setFlights] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); @@ -65,17 +68,12 @@ export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd, const SCREEN_H = Dimensions.get('window').height; const fetchFlights = useCallback(async () => { + if (airportLoading) return; setLoading(true); setError(false); try { - const res = await fetch( - 'https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100', - { headers: { 'User-Agent': 'Mozilla/5.0' } }, - ); - const json = await res.json(); - const raw: any[] = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; - const filtered = raw - .filter(i => ALLOWED_AIRLINES.some(k => (i.flight?.airline?.name || '').toLowerCase().includes(k))) + const { departures } = await fetchAirportScheduleRaw(airportCode); + const filtered = departures .map(parseFlight) .filter((f): f is Flight => f !== null && f.departureTs >= startSec && f.departureTs <= endSec) .sort((a, b) => a.departureTs - b.departureTs); @@ -85,10 +83,11 @@ export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd, } finally { setLoading(false); } - }, [startSec, endSec]); + }, [airportCode, airportLoading, startSec, endSec]); // Inline: carica subito; Modal: carica quando visibile useEffect(() => { + if (airportLoading) return; if (inline || visible) { fetchFlights(); setExpandedId(null); @@ -96,7 +95,7 @@ export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd, const interval = setInterval(() => setNowSec(Date.now() / 1000), 60000); return () => clearInterval(interval); } - }, [inline, visible, fetchFlights]); + }, [inline, visible, airportLoading, fetchFlights]); const toggleExpand = (id: string) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); diff --git a/src/context/AirportContext.tsx b/src/context/AirportContext.tsx new file mode 100644 index 0000000..39d548d --- /dev/null +++ b/src/context/AirportContext.tsx @@ -0,0 +1,52 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { + DEFAULT_AIRPORT_CODE, + getAirportInfo, + getStoredAirportCode, + setStoredAirportCode, + type AirportInfo, +} from '../utils/airportSettings'; + +type AirportContextValue = { + airportCode: string; + airport: AirportInfo; + setAirportCode: (code: string) => Promise; + isLoading: boolean; +}; + +const defaultAirport = getAirportInfo(DEFAULT_AIRPORT_CODE); + +const AirportContext = createContext({ + airportCode: defaultAirport.code, + airport: defaultAirport, + setAirportCode: async () => {}, + isLoading: false, +}); + +export function AirportProvider({ children }: { children: React.ReactNode }) { + const [airportCode, setAirportCodeState] = useState(DEFAULT_AIRPORT_CODE); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getStoredAirportCode() + .then(code => setAirportCodeState(code)) + .finally(() => setIsLoading(false)); + }, []); + + const setAirportCode = useCallback(async (code: string) => { + const savedCode = await setStoredAirportCode(code); + setAirportCodeState(savedCode); + }, []); + + const airport = useMemo(() => getAirportInfo(airportCode), [airportCode]); + + return ( + + {children} + + ); +} + +export function useAirport() { + return useContext(AirportContext); +} diff --git a/src/hooks/useDynamicTheme.ts b/src/hooks/useDynamicTheme.ts index 5b56ae7..b32b0eb 100644 --- a/src/hooks/useDynamicTheme.ts +++ b/src/hooks/useDynamicTheme.ts @@ -70,81 +70,77 @@ const themes: Record = { } }; -// Variabile globale per mantenere in memoria il tema anche cambiando scheda +// Global cache β€” shared across all consumers so the fetch runs only once. +// A Promise is stored while a fetch is in-flight so late subscribers can await +// the same result instead of triggering a duplicate request (fixes race condition). let globalCachedTheme: Theme | null = null; -let isFetchingTheme = false; +let pendingFetch: Promise | null = null; + +async function resolveTheme(): Promise { + if (globalCachedTheme) return globalCachedTheme; + if (pendingFetch) return pendingFetch; + + pendingFetch = (async () => { + try { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') return themes.default; + + const location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); + const { latitude: lat, longitude: lon } = location.coords; + + const response = await fetch( + `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t_weather=true`, + ); + const data = await response.json(); + + const weatherCode: number = data.current_weather?.weathercode ?? 0; + const isDay: number = data.current_weather?.is_day ?? 1; + const hour = new Date().getHours(); + + let timePeriod = 'night'; + if (hour >= 6 && hour < 12) timePeriod = 'morning'; + else if (hour >= 12 && hour < 18) timePeriod = 'afternoon'; + else if (hour >= 18 && hour < 20) timePeriod = 'evening'; + + let weatherType = 'clear'; + if (weatherCode === 3) weatherType = 'cloudy'; + else if (weatherCode >= 45 && weatherCode <= 67) weatherType = 'rain'; + else if (weatherCode >= 80) weatherType = 'rain'; + + let selected = themes.default; + if (weatherType === 'cloudy') selected = themes.cloudy; + else if (weatherType === 'rain') selected = themes.rain; + else if (!isDay || timePeriod === 'night') selected = themes.night_clear; + else if (timePeriod === 'morning') selected = themes.morning_clear; + else if (timePeriod === 'afternoon') selected = themes.afternoon_clear; + else if (timePeriod === 'evening') selected = themes.evening_clear; + + globalCachedTheme = selected; + return selected; + } catch (err) { + console.warn('Errore caricamento tema dinamico:', err); + return themes.default; + } finally { + pendingFetch = null; + } + })(); + + return pendingFetch; +} export function useDynamicTheme() { const [theme, setTheme] = useState(globalCachedTheme || themes.default); const [loading, setLoading] = useState(!globalCachedTheme); useEffect(() => { - let isMounted = true; - - async function fetchTheme() { - if (globalCachedTheme) return; // Se giΓ  calcolato, evita ricaricamenti nulli - if (isFetchingTheme) return; - isFetchingTheme = true; - - try { - const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - if (isMounted) setTheme(themes.default); - return; - } - - // Reduced accuracy massively speeds up GPS locks indoors! - const location = await Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }); - const lat = location.coords.latitude; - const lon = location.coords.longitude; - - // Fetch meteo mondiale gratuito e senza chiavi (Open-Meteo) - const response = await fetch(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t_weather=true`); - const data = await response.json(); - - const weatherCode = data.current_weather.weathercode; - const isDay = data.current_weather.is_day; // 1 se giorno, 0 se notte - - // Orario locale per distinguere mattina/pomeriggio/sera - const hour = new Date().getHours(); - - let timePeriod = 'night'; - if (hour >= 6 && hour < 12) timePeriod = 'morning'; - else if (hour >= 12 && hour < 18) timePeriod = 'afternoon'; - else if (hour >= 18 && hour < 20) timePeriod = 'evening'; - - // Categorizzazione WMO meteorologica - let weatherType = 'clear'; - if (weatherCode === 3) weatherType = 'cloudy'; - else if (weatherCode >= 45 && weatherCode <= 67) weatherType = 'rain'; - else if (weatherCode >= 80) weatherType = 'rain'; - - let selectedTheme = themes.default; - - // Logica prioritaria di fusione (Il meteo "brutto" sovrascrive il sole, altrimenti decide l'ora) - if (weatherType === 'cloudy') { - selectedTheme = themes.cloudy; - } else if (weatherType === 'rain') { - selectedTheme = themes.rain; - } else { - if (!isDay || timePeriod === 'night') selectedTheme = themes.night_clear; - else if (timePeriod === 'morning') selectedTheme = themes.morning_clear; - else if (timePeriod === 'afternoon') selectedTheme = themes.afternoon_clear; - else if (timePeriod === 'evening') selectedTheme = themes.evening_clear; - } - - globalCachedTheme = selectedTheme; - if (isMounted) setTheme(selectedTheme); - } catch (err) { - console.warn("Errore caricamento tema dinamico:", err); - } finally { - if (isMounted) setLoading(false); - isFetchingTheme = false; + let cancelled = false; + resolveTheme().then(t => { + if (!cancelled) { + setTheme(t); + setLoading(false); } - } - - fetchTheme(); - return () => { isMounted = false; }; + }); + return () => { cancelled = true; }; }, []); return { theme, loadingTheme: loading }; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index d0379a8..1266625 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef, useMemo } from 'react'; import { View, Text, StyleSheet, ActivityIndicator, ScrollView, TouchableOpacity, - PanResponder, Platform, UIManager, Animated, Dimensions, Modal, Alert, FlatList, TextInput, KeyboardAvoidingView, + PanResponder, Platform, UIManager, Animated, Dimensions, Modal, Alert, FlatList, TextInput, KeyboardAvoidingView, Keyboard, } from 'react-native'; import * as SystemCalendar from 'expo-calendar'; import * as Location from 'expo-location'; @@ -11,6 +11,13 @@ import { WebView } from 'react-native-webview'; import { MaterialIcons } from '@expo/vector-icons'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAppTheme } from '../context/ThemeContext'; +import { useAirport } from '../context/AirportContext'; +import { fetchAirportScheduleRaw } from '../utils/fr24api'; +import { + getWritableCalendarId, + replaceShiftForDate, + replaceShiftsForRange, +} from '../utils/shiftCalendar'; import { getPdfExtractorHtml, parseShiftCells, type ParsedSchedule, type ParsedEmployee, type ParsedShift, @@ -41,7 +48,8 @@ const weatherMap: Record = { 80: { text: 'Rovesci', icon: '🌧️' }, }; -function getMonday(d: Date) { +function getMonday(d: Date | null | undefined): Date { + if (!d || isNaN(d.getTime())) return getMonday(new Date()); const date = new Date(d); const day = date.getDay(); date.setDate(date.getDate() - day + (day === 0 ? -6 : 1)); @@ -50,6 +58,7 @@ function getMonday(d: Date) { export default function CalendarScreen() { const { colors } = useAppTheme(); + const { airportCode, isLoading: airportLoading } = useAirport(); const [currentWeekStart, setCurrentWeekStart] = useState(getMonday(new Date())); const [selectedDay, setSelectedDay] = useState(new Date().toISOString().split('T')[0]); const [markedDates, setMarkedDates] = useState>({}); @@ -75,6 +84,9 @@ export default function CalendarScreen() { const [manualStartM, setManualStartM] = useState('00'); const [manualEndH, setManualEndH] = useState('16'); const [manualEndM, setManualEndM] = useState('00'); + const manualStartMRef = useRef(null); + const manualEndHRef = useRef(null); + const manualEndMRef = useRef(null); const openManualEntry = () => { setEditMenuOpen(false); @@ -85,31 +97,27 @@ export default function CalendarScreen() { setManualModalOpen(true); }; + const sanitizeTimePart = (value: string) => value.replace(/\D/g, '').slice(0, 2); + const saveManualShift = async () => { - if (!calId) { - const { status } = await SystemCalendar.requestCalendarPermissionsAsync(); - if (status !== 'granted') { Alert.alert('Permesso negato'); return; } - const cals = await SystemCalendar.getCalendarsAsync(SystemCalendar.EntityTypes.EVENT); - const cal = cals.find(c => c.allowsModifications && c.isPrimary) || cals.find(c => c.allowsModifications); - if (!cal) { Alert.alert('Errore', 'Nessun calendario scrivibile'); return; } - setCalId(cal.id); - } + const { status } = await SystemCalendar.requestCalendarPermissionsAsync(); + if (status !== 'granted') { Alert.alert('Permesso negato'); return; } + try { - const [y, m, d] = manualDate.split('-').map(Number); - if (manualType === 'Riposo') { - await SystemCalendar.createEventAsync(calId!, { - title: 'Riposo', startDate: new Date(y, m - 1, d, 0, 0), endDate: new Date(y, m - 1, d, 23, 59), timeZone: 'Europe/Rome', - }); - } else { - const start = new Date(y, m - 1, d, +manualStartH, +manualStartM); - const end = new Date(y, m - 1, d, +manualEndH, +manualEndM); - if (end <= start) end.setDate(end.getDate() + 1); - await SystemCalendar.createEventAsync(calId!, { - title: 'Lavoro', startDate: start, endDate: end, timeZone: 'Europe/Rome', - }); - } + const calendarId = calId ?? await getWritableCalendarId(); + if (!calendarId) { Alert.alert('Errore', 'Nessun calendario scrivibile'); return; } + if (!calId) setCalId(calendarId); + + await replaceShiftForDate({ + calendarId, + date: manualDate, + type: manualType === 'Riposo' ? 'rest' : 'work', + startTime: manualType === 'Lavoro' ? `${manualStartH.padStart(2, '0')}:${manualStartM.padStart(2, '0')}` : undefined, + endTime: manualType === 'Lavoro' ? `${manualEndH.padStart(2, '0')}:${manualEndM.padStart(2, '0')}` : undefined, + }); + setManualModalOpen(false); - fetchCalendar(); + fetchCalendar(true); Alert.alert('Turno salvato!'); } catch (e: any) { Alert.alert('Errore', e.message); } }; @@ -165,11 +173,13 @@ export default function CalendarScreen() { }; }); - useEffect(() => { fetchCalendar(); }, [currentWeekStart]); + useEffect(() => { + if (!airportLoading) fetchCalendar(); + }, [currentWeekStart, airportCode, airportLoading]); - const fetchCalendar = async () => { + const fetchCalendar = async (silent = false) => { try { - setLoading(true); + if (!silent) setLoading(true); const { status } = await SystemCalendar.requestCalendarPermissionsAsync(); if (status !== 'granted') { setLoading(false); return; } const calendars = await SystemCalendar.getCalendarsAsync(SystemCalendar.EntityTypes.EVENT); @@ -214,21 +224,23 @@ export default function CalendarScreen() { dict[date] = { weatherText: m.text, weatherIcon: m.icon, flightCount: 0 }; }); } - } catch (_) {} + } catch (e) { console.warn('[calWeather]', e); } try { - const fr = await fetch('https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100', { headers: { 'User-Agent': 'Mozilla/5.0' } }); - const fj = await fr.json(); - const allF = [...(fj.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []), ...(fj.result?.response?.airport?.pluginData?.schedule?.departures?.data || [])]; - const allowed = ['wizz', 'aer lingus', 'easyjet', 'british airways', 'sas', 'scandinavian', 'flydubai']; + const { arrivals, departures } = await fetchAirportScheduleRaw(airportCode); + const allF = [...arrivals, ...departures]; Object.keys(localData).forEach(iso => { const sh = localData[iso].find(e => e.title.includes('Lavoro')); if (sh) { - const sTS = new Date(sh.startDate).getTime() / 1000, eTS = new Date(sh.endDate).getTime() / 1000; - const cnt = allF.filter(f => { const a = (f.flight?.airline?.name || '').toLowerCase(); if (!allowed.some(k => a.includes(k))) return false; const ts = f.flight?.time?.scheduled?.arrival || f.flight?.time?.scheduled?.departure; return ts && ts >= sTS && ts <= eTS; }).length; + const sTS = new Date(sh.startDate).getTime() / 1000; + const eTS = new Date(sh.endDate).getTime() / 1000; + const cnt = allF.filter(f => { + const ts = f.flight?.time?.scheduled?.arrival || f.flight?.time?.scheduled?.departure; + return ts && ts >= sTS && ts <= eTS; + }).length; if (dict[iso]) dict[iso].flightCount = cnt; else dict[iso] = { weatherText: 'N/A', weatherIcon: '❓', flightCount: cnt }; } }); - } catch (_) {} + } catch (e) { console.warn('[calFlights]', e); } setDailyStats(dict); }; @@ -315,54 +327,33 @@ export default function CalendarScreen() { }; const confirmImport = async () => { - if (!selectedEmployee || !calId) return; + if (!selectedEmployee) return; setImportStep('saving'); try { - // Delete existing Lavoro/Riposo events for the imported date range - const shiftDates = selectedEmployee.shifts.map(s => s.date).sort(); - if (shiftDates.length > 0) { - const [fy, fm, fd] = shiftDates[0].split('-').map(Number); - const [ly, lm, ld] = shiftDates[shiftDates.length - 1].split('-').map(Number); - const rangeStart = new Date(fy, fm - 1, fd, 0, 0); - const rangeEnd = new Date(ly, lm - 1, ld, 23, 59); - const existing = await SystemCalendar.getEventsAsync([calId], rangeStart, rangeEnd); - for (const e of existing) { - if (e.title.includes('Lavoro') || e.title.includes('Riposo')) { - await SystemCalendar.deleteEventAsync(e.id); - } - } - } - - let saved = 0; - for (const shift of selectedEmployee.shifts) { - const [y, m, d] = shift.date.split('-').map(Number); - if (shift.type === 'work' && shift.start && shift.end) { - const [sh, sm] = shift.start.split(':').map(Number); - const [eh, em] = shift.end.split(':').map(Number); - await SystemCalendar.createEventAsync(calId, { - title: 'Lavoro', - startDate: new Date(y, m - 1, d, sh, sm), - endDate: new Date(y, m - 1, d, eh, em), - timeZone: 'Europe/Rome', - }); - saved++; - } else if (shift.type === 'rest') { - await SystemCalendar.createEventAsync(calId, { - title: 'Riposo', - startDate: new Date(y, m - 1, d, 0, 0), - endDate: new Date(y, m - 1, d, 23, 59), - timeZone: 'Europe/Rome', - }); - saved++; - } + const calendarId = calId ?? await getWritableCalendarId(); + if (!calendarId) { + Alert.alert('Errore', 'Nessun calendario scrivibile'); + setImportStep('idle'); + return; } + if (!calId) setCalId(calendarId); + + const saved = await replaceShiftsForRange({ + calendarId, + shifts: selectedEmployee.shifts.map(shift => ({ + date: shift.date, + type: shift.type, + startTime: shift.start, + endTime: shift.end, + })), + }); setImportStep('done'); setTimeout(() => { setImportModalVisible(false); setImportStep('idle'); - fetchCalendar(); + fetchCalendar(true); Alert.alert('Importazione completata', `${saved} turni salvati nel calendario`); }, 800); } catch (e) { @@ -437,7 +428,7 @@ export default function CalendarScreen() { {stats.weatherIcon} - Pisa + Meteo locale {stats.weatherText} @@ -504,12 +495,12 @@ export default function CalendarScreen() { setManualModalOpen(false)}> - - - + + + Aggiungi Turno setManualModalOpen(false)}> @@ -546,14 +537,64 @@ export default function CalendarScreen() { {manualType === 'Lavoro' && ( <> ORARIO INIZIO - - - + + setManualStartH(sanitizeTimePart(v))} + selectTextOnFocus + returnKeyType="next" + blurOnSubmit={false} + onSubmitEditing={() => manualStartMRef.current?.focus()} + /> + setManualStartM(sanitizeTimePart(v))} + selectTextOnFocus + returnKeyType="next" + blurOnSubmit={false} + onSubmitEditing={() => manualEndHRef.current?.focus()} + /> ORARIO FINE - - - + + setManualEndH(sanitizeTimePart(v))} + selectTextOnFocus + returnKeyType="next" + blurOnSubmit={false} + onSubmitEditing={() => manualEndMRef.current?.focus()} + /> + setManualEndM(sanitizeTimePart(v))} + selectTextOnFocus + returnKeyType="done" + onSubmitEditing={Keyboard.dismiss} + /> )} @@ -562,7 +603,7 @@ export default function CalendarScreen() { Salva Turno - + @@ -736,8 +777,9 @@ function makeStyles(c: any) { // Modal modalOverlay: { flex: 1, justifyContent: 'flex-end' }, modalBg: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, - modalScrollContent: { flexGrow: 1, justifyContent: 'flex-end' }, + modalScrollContent: { flex: 1, justifyContent: 'flex-end' }, modalContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 100, maxHeight: '92%' }, + manualModalContent: { borderTopLeftRadius: 24, borderTopRightRadius: 24, padding: 24, paddingBottom: 32, maxHeight: '92%' }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, modalTitle: { fontSize: 20, fontWeight: 'bold' }, centerBox: { alignItems: 'center', paddingVertical: 40, gap: 12 }, @@ -761,6 +803,8 @@ function makeStyles(c: any) { // Manual entry manualLabel: { fontSize: 11, fontWeight: '700', letterSpacing: 1, marginBottom: 6 }, manualInput: { borderWidth: 1, borderRadius: 10, paddingHorizontal: 14, paddingVertical: 12, fontSize: 16, marginBottom: 4 }, + manualTimeRow: { flexDirection: 'row', gap: 10, marginBottom: 14 }, + manualTimeInput: { flex: 1, textAlign: 'center' }, manualTypeBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1.5, alignItems: 'center' }, }); } diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index b64d9b1..155fb94 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -9,8 +9,10 @@ import * as Notifications from 'expo-notifications'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme } from '../context/ThemeContext'; -import { getAirlineOps, getAirlineColor, ALLOWED_AIRLINES } from '../utils/airlineOps'; -import { fetchPSAScheduleRaw } from '../utils/fr24api'; +import { useAirport } from '../context/AirportContext'; +import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; +import { fetchAirportScheduleRaw } from '../utils/fr24api'; +import { formatAirportHeader } from '../utils/airportSettings'; import { requestWidgetUpdate } from 'react-native-android-widget'; import { WIDGET_CACHE_KEY } from '../widgets/widgetTaskHandler'; import type { WidgetData, WidgetFlight } from '../widgets/widgetTaskHandler'; @@ -32,12 +34,8 @@ try { Notifications.setNotificationHandler({ shouldShowBanner: true, shouldShowList: true, }), -}); } catch {} +}); } catch (e) { console.warn('[notifHandler]', e); } -const PRIMARY = '#2563EB'; -const DARK_BLUE = '#1E3A8A'; -const GOLD = '#F59E0B'; -const BG = '#F3F4F6'; function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineName: string; color: string }) { const [err, setErr] = useState(false); @@ -234,6 +232,7 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu // ─── Screen ──────────────────────────────────────────────────────────────────── export default function FlightScreen() { const { colors } = useAppTheme(); + const { airport, airportCode, isLoading: airportLoading } = useAirport(); const s = useMemo(() => makeStyles(colors), [colors]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -252,17 +251,15 @@ export default function FlightScreen() { AsyncStorage.getItem(NOTIF_ENABLED_KEY).then(v => setNotifsEnabled(v === 'true')); }, []); - const fetchAll = async () => { + const fetchAll = useCallback(async () => { + if (airportLoading) return; + try { - const res = await fetch('https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100', { - headers: { 'User-Agent': 'Mozilla/5.0' }, - }); - const json = await res.json(); - const filter = (data: any[]) => data.filter(i => ALLOWED_AIRLINES.some(k => (i.flight?.airline?.name || '').toLowerCase().includes(k))); - const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []; - const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; - const fetchedArrivals = filter(allArrivals); - const fetchedDepartures = filter(allDepartures); + const { + allArrivals, + departures: fetchedDepartures, + arrivals: fetchedArrivals, + } = await fetchAirportScheduleRaw(airportCode); // Build inbound arrival map: registration β†’ best known arrival timestamp const inboundMap: Record = {}; @@ -279,7 +276,7 @@ export default function FlightScreen() { setArrivals(fetchedArrivals); setDepartures(fetchedDepartures); - // Auto-clear expired pinned flight + // Auto-clear expired pinned flight or stale data from another airport const pinnedRaw = await AsyncStorage.getItem(PINNED_FLIGHT_KEY); if (pinnedRaw) { try { @@ -288,7 +285,10 @@ export default function FlightScreen() { const pinTs = pinTab === 'arrivals' ? pinned.flight?.time?.scheduled?.arrival : pinned.flight?.time?.scheduled?.departure; - if (pinTs && pinTs < Date.now() / 1000) { + const pinId = pinned.flight?.identification?.number?.default; + const pool = pinTab === 'arrivals' ? fetchedArrivals : fetchedDepartures; + const stillPresent = !!pinId && pool.some(item => item.flight?.identification?.number?.default === pinId); + if ((pinTs && pinTs < Date.now() / 1000) || !stillPresent) { await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); await cancelPinnedNotifications(); setPinnedFlightId(null); @@ -396,10 +396,14 @@ export default function FlightScreen() { await cancelPreviousNotifications(); setScheduledCount(0); } - } catch (e) { console.error(e); } finally { setLoading(false); setRefreshing(false); } - }; + } catch (e) { console.error('[fetchAll]', e); } finally { setLoading(false); setRefreshing(false); } + }, [airportCode, airportLoading]); - useEffect(() => { fetchAll(); }, []); + useEffect(() => { + if (airportLoading) return; + setLoading(true); + fetchAll(); + }, [airportLoading, fetchAll]); useEffect(() => { AsyncStorage.getItem(PINNED_FLIGHT_KEY).then(raw => { @@ -457,7 +461,7 @@ export default function FlightScreen() { const tab = activeTab; await AsyncStorage.setItem(PINNED_FLIGHT_KEY, JSON.stringify({ ...item, _pinTab: tab, _pinnedAt: Date.now() })); setPinnedFlightId(id); - try { await schedulePinnedNotifications(item, tab); } catch {} + try { await schedulePinnedNotifications(item, tab); } catch (e) { console.warn('[pinnedNotif]', e); } // Send to watch if (WearDataSender) { const payload = JSON.stringify({ @@ -484,10 +488,10 @@ export default function FlightScreen() { const unpinFlight = useCallback(async () => { try { await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); - try { await cancelPinnedNotifications(); } catch {} + try { await cancelPinnedNotifications(); } catch (e) { console.warn('[cancelPinNotif]', e); } setPinnedFlightId(null); if (WearDataSender) WearDataSender.clearPinnedFlight(); - } catch {} + } catch (e) { console.error('[unpin]', e); } }, []); const userShift = activeDay === 'today' ? shifts.today : shifts.tomorrow; @@ -660,12 +664,15 @@ export default function FlightScreen() { Voli in tempo reale - Pisa International Β· PSA / LIRP + {formatAirportHeader(airport.code)} = { 0: { text: 'Sereno', icon: 'β˜€οΈ' }, 1: { text: 'Poco Nuvoloso', icon: '🌀️' }, @@ -187,7 +191,7 @@ export default function HomeScreen() { }; loadPinned(); - const interval = setInterval(loadPinned, 2000); + const interval = setInterval(loadPinned, 30_000); return () => clearInterval(interval); }, []); @@ -211,34 +215,32 @@ export default function HomeScreen() { const { status } = await Calendar.requestCalendarPermissionsAsync(); if (status !== 'granted') { Alert.alert('Permesso negato', 'Autorizza il calendario.'); return; } try { - const cals = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); - const cal = cals.find(c => c.allowsModifications && c.isPrimary) || cals.find(c => c.allowsModifications); - if (!cal) { Alert.alert('Errore', 'Nessun calendario scrivibile.'); return; } + const calendarId = await getWritableCalendarId(); + if (!calendarId) { Alert.alert('Errore', 'Nessun calendario scrivibile.'); return; } const todayDate = new Date(); - const y = todayDate.getFullYear(), m = todayDate.getMonth() + 1, d = todayDate.getDate(); - - if (shiftEvent) { - await Calendar.deleteEventAsync(shiftEvent.id); - } + const y = todayDate.getFullYear(); + const m = todayDate.getMonth() + 1; + const d = todayDate.getDate(); + const date = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; + + await replaceShiftForDate({ + calendarId, + date, + type: newShiftType === 'Riposo' ? 'rest' : 'work', + startTime: newShiftType === 'Lavoro' ? `${newStartH.padStart(2, '0')}:${newStartM.padStart(2, '0')}` : undefined, + endTime: newShiftType === 'Lavoro' ? `${newEndH.padStart(2, '0')}:${newEndM.padStart(2, '0')}` : undefined, + titles: HOME_SHIFT_TITLES, + restTiming: HOME_REST_TIMING, + }); - if (newShiftType === 'Riposo') { - const ds = new Date(y, m - 1, d, 12, 0, 0); - const de = new Date(y, m - 1, d, 14, 0, 0); - await Calendar.createEventAsync(cal.id, { title: '🌴 Riposo', startDate: ds, endDate: de, allDay: true, timeZone: 'Europe/Rome' }); - } else { - const start = new Date(y, m - 1, d, +newStartH, +newStartM); - const end = new Date(y, m - 1, d, +newEndH, +newEndM); - if (end <= start) end.setDate(end.getDate() + 1); - await Calendar.createEventAsync(cal.id, { title: 'Turno Lavoro ✈️', startDate: start, endDate: end, timeZone: 'Europe/Rome' }); - } setShiftModalOpen(false); - fetchShift(); + fetchShift(true); } catch (e: any) { Alert.alert('Errore', e.message); } }; - const fetchShift = async () => { - setLoadingShift(true); + const fetchShift = async (silent = false) => { + if (!silent) setLoadingShift(true); try { const { status } = await Calendar.requestCalendarPermissionsAsync(); if (status !== 'granted') { setLoadingShift(false); return; } @@ -251,7 +253,7 @@ export default function HomeScreen() { const events = await Calendar.getEventsAsync([cal.id], d, dEnd); const shift = events.find(e => e.title.includes('Lavoro') || e.title.includes('Riposo')); setShiftEvent(shift || null); - } catch (_) {} finally { setLoadingShift(false); } + } catch (e) { console.error('[shift]', e); } finally { setLoadingShift(false); } }; const fetchWeather = async () => { @@ -265,7 +267,7 @@ export default function HomeScreen() { const temp = Math.round(json.current?.temperature_2m ?? 0); const w = weatherMap[code] || { text: 'Sereno', icon: 'β˜€οΈ' }; setWeather({ ...w, temp }); - } catch (_) {} + } catch (e) { console.warn('[weather]', e); } }; const pickImage = async () => { @@ -278,27 +280,34 @@ export default function HomeScreen() { setImageList(result.assets.map(a => a.uri)); setProcessing(true); setOcrText(''); const base64List = result.assets.map(a => `data:image/jpeg;base64,${a.base64}`); - const base64Json = JSON.stringify(base64List).replace(/'/g, "\\'"); - webViewRef.current?.injectJavaScript(`if(window.runTesseract){window.runTesseract('${base64Json}');}else{window.ReactNativeWebView.postMessage(JSON.stringify({success:false,error:'OCR non pronto'}));}true;`); + const base64Json = JSON.stringify(base64List); + // Use postMessage pattern to avoid script-injection risks with injectJavaScript + webViewRef.current?.injectJavaScript(` + if(window.runTesseract){ + window.runTesseract(${JSON.stringify(base64Json)}); + } else { + window.ReactNativeWebView.postMessage(JSON.stringify({success:false,error:'OCR non pronto'})); + } + true; + `); } - } catch (_) { setProcessing(false); } + } catch (e) { console.error('[imagePicker]', e); setProcessing(false); } }; const handleWebViewMessage = (event: any) => { try { const r = JSON.parse(event.nativeEvent.data); if (r.success) setOcrText(r.text); - else Alert.alert('Errore OCR', r.error); - } catch (_) {} finally { setProcessing(false); } + else Alert.alert('Errore riconoscimento testo', r.error || 'Prova con un\'immagine piΓΉ nitida o meglio illuminata.'); + } catch (e) { console.error('[ocrMessage]', e); } finally { setProcessing(false); } }; const parseAndSave = async () => { const { status } = await Calendar.requestCalendarPermissionsAsync(); if (status !== 'granted') { Alert.alert('Permesso negato', 'Autorizza il calendario.'); return; } try { - const cals = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); - const cal = cals.find(c => c.allowsModifications && c.isPrimary) || cals.find(c => c.allowsModifications); - if (!cal) { Alert.alert('Errore', 'Nessun calendario scrivibile.'); return; } + const calendarId = await getWritableCalendarId(); + if (!calendarId) { Alert.alert('Errore', 'Nessun calendario scrivibile.'); return; } const norText = ocrText.replace(/[OoQ]/g, '0').replace(/[Il|]/g, '1'); const dateRegex = /\b(\d{2})[\/\-](\d{2})[\/\-](\d{4})\b/g; const dates: any[] = []; let m; @@ -310,26 +319,34 @@ export default function HomeScreen() { if (m[5]) shifts.push({ isRest: true, raw: m[0] }); else shifts.push({ isRest: false, startH: +m[1], startM: +m[2], endH: +m[3], endM: +m[4], raw: m[0] }); } - let saved = 0; + + const parsedShifts = []; for (let i = 0; i < Math.min(dates.length, shifts.length); i++) { - const d = dates[i], s = shifts[i]; - const ds = new Date(d.year, d.month, d.day, 0, 0, 0); - const de = new Date(d.year, d.month, d.day, 23, 59, 59); - const existing = await Calendar.getEventsAsync([cal.id], ds, de); - const dup = existing.some(e => s.isRest ? e.title.includes('Riposo') : (e.title.includes('Lavoro') && new Date(e.startDate).getHours() === s.startH)); - if (dup) continue; + const d = dates[i]; + const s = shifts[i]; + const date = `${d.year}-${String(d.month + 1).padStart(2, '0')}-${String(d.day).padStart(2, '0')}`; + if (s.isRest) { - await Calendar.createEventAsync(cal.id, { title: '🌴 Riposo', startDate: new Date(d.year, d.month, d.day, 12, 0, 0), endDate: new Date(d.year, d.month, d.day, 14, 0, 0), allDay: true, timeZone: 'Europe/Rome' }); + parsedShifts.push({ date, type: 'rest' as const }); } else { - const start = new Date(d.year, d.month, d.day); start.setHours(s.startH, s.startM, 0, 0); - const end = new Date(d.year, d.month, d.day); end.setHours(s.endH, s.endM, 0, 0); - if (end <= start) end.setDate(end.getDate() + 1); - await Calendar.createEventAsync(cal.id, { title: 'Turno Lavoro ✈️', startDate: start, endDate: end, timeZone: 'Europe/Rome' }); + parsedShifts.push({ + date, + type: 'work' as const, + startTime: `${String(s.startH).padStart(2, '0')}:${String(s.startM).padStart(2, '0')}`, + endTime: `${String(s.endH).padStart(2, '0')}:${String(s.endM).padStart(2, '0')}`, + }); } - saved++; } + + const saved = await replaceShiftsForRange({ + calendarId, + shifts: parsedShifts, + titles: HOME_SHIFT_TITLES, + restTiming: HOME_REST_TIMING, + }); + Alert.alert(saved > 0 ? 'βœ… Turni Sincronizzati!' : 'Nessun orario trovato', saved > 0 ? `${saved} turni salvati.` : `Date: ${dates.length}, Orari: ${shifts.length}`); - if (saved > 0) fetchShift(); + if (saved > 0) fetchShift(true); } catch (e: any) { Alert.alert('Errore Calendario', e.message); } }; @@ -351,7 +368,7 @@ export default function HomeScreen() { <> {weather.icon} {weather.temp}Β° - Pisa β€’ {weather.text} + Meteo locale β€’ {weather.text} ) : ( @@ -461,5 +478,3 @@ function makeStyles(c: any) { modeBtnText: { color: '#fff', fontWeight: '700', fontSize: 13 }, }); } - - diff --git a/src/screens/PasswordScreen.tsx b/src/screens/PasswordScreen.tsx index 240cd81..54ed239 100644 --- a/src/screens/PasswordScreen.tsx +++ b/src/screens/PasswordScreen.tsx @@ -4,6 +4,7 @@ import { Modal, TextInput, Alert, KeyboardAvoidingView, Platform, ScrollView, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme } from '../context/ThemeContext'; @@ -11,6 +12,19 @@ const PASSWORDS_KEY = 'aerostaff_passwords_v1'; const PIN_KEY = 'aerostaff_pin_v1'; const PIN_ENABLED_KEY = 'aerostaff_pin_enabled_v1'; +// Secure helpers β€” PIN is stored in the OS keychain, not plain AsyncStorage. +async function getSecurePin(): Promise { + try { return await SecureStore.getItemAsync(PIN_KEY); } + catch { return AsyncStorage.getItem(PIN_KEY); } // fallback for older installs +} +async function setSecurePin(pin: string): Promise { + await SecureStore.setItemAsync(PIN_KEY, pin); +} +async function deleteSecurePin(): Promise { + await SecureStore.deleteItemAsync(PIN_KEY).catch(() => {}); + await AsyncStorage.removeItem(PIN_KEY).catch(() => {}); // clean up legacy +} + type PasswordEntry = { id: string; name: string; @@ -149,9 +163,11 @@ export default function PasswordScreen() { Alert.alert('Disattiva PIN', 'Vuoi rimuovere la protezione PIN?', [ { text: 'Annulla', style: 'cancel' }, { text: 'Disattiva', style: 'destructive', onPress: async () => { - setPinEnabled(false); - await AsyncStorage.setItem(PIN_ENABLED_KEY, 'false'); - await AsyncStorage.removeItem(PIN_KEY); + try { + setPinEnabled(false); + await AsyncStorage.setItem(PIN_ENABLED_KEY, 'false'); + await deleteSecurePin(); + } catch (e) { console.error('[pin] disable error', e); } }}, ]); } else { @@ -160,19 +176,29 @@ export default function PasswordScreen() { }, [pinEnabled]); const handlePinSetup = useCallback(async (pin: string) => { - await AsyncStorage.setItem(PIN_KEY, pin); - await AsyncStorage.setItem(PIN_ENABLED_KEY, 'true'); - setPinEnabled(true); - setPinMode(null); - Alert.alert('PIN impostato', 'La schermata password Γ¨ ora protetta.'); + try { + await setSecurePin(pin); + await AsyncStorage.setItem(PIN_ENABLED_KEY, 'true'); + setPinEnabled(true); + setPinMode(null); + Alert.alert('PIN impostato', 'La schermata password Γ¨ ora protetta.'); + } catch (e) { + console.error('[pin] setup error', e); + Alert.alert('Errore', 'Impossibile impostare il PIN. Riprova.'); + } }, []); const handlePinUnlock = useCallback(async (pin: string) => { - const stored = await AsyncStorage.getItem(PIN_KEY); - if (pin === stored) { - setPinMode(null); - } else { - Alert.alert('PIN errato', 'Riprova.'); + try { + const stored = await getSecurePin(); + if (pin === stored) { + setPinMode(null); + } else { + Alert.alert('PIN errato', 'Riprova.'); + } + } catch (e) { + console.error('[pin] unlock error', e); + Alert.alert('Errore', 'Impossibile verificare il PIN. Riprova.'); } }, []); @@ -232,7 +258,13 @@ export default function PasswordScreen() { Password - + diff --git a/src/screens/PhonebookScreen.tsx b/src/screens/PhonebookScreen.tsx index 574e9aa..5f2cf27 100644 --- a/src/screens/PhonebookScreen.tsx +++ b/src/screens/PhonebookScreen.tsx @@ -262,12 +262,12 @@ function ContactRow({ contact, onEdit, onDelete }: ContactRowProps) { - {contact.name} + {contact.name} {contact.category} - {contact.number} + {contact.number} {!!contact.note && {contact.note}} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index f6c3e32..80414ea 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,9 +1,18 @@ -import React from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Switch, ActivityIndicator, Alert } from 'react-native'; +import React, { useState } from 'react'; +import { + View, Text, StyleSheet, ScrollView, TouchableOpacity, Switch, ActivityIndicator, + Alert, Modal, KeyboardAvoidingView, Platform, TextInput, +} from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme, ThemeMode } from '../context/ThemeContext'; -import * as Notifications from 'expo-notifications'; +import { useAirport } from '../context/AirportContext'; +import { + AIRPORT_PRESETS, + formatAirportSettingLabel, + normalizeAirportCode, + isValidAirportCode, +} from '../utils/airportSettings'; // ─── Tema picker ────────────────────────────────────────────────────────────── type ThemeOption = { @@ -106,7 +115,7 @@ function ThemeCard({ option, selected, onSelect }: { // ─── Riga impostazione generica ─────────────────────────────────────────────── function SettingRow({ - icon, label, sublabel, type, value, onToggle, disabled, + icon, label, sublabel, type, value, onToggle, onPress, disabled, }: { icon: keyof typeof MaterialIcons.glyphMap; label: string; @@ -114,11 +123,13 @@ function SettingRow({ type: 'arrow' | 'toggle' | 'info'; value?: boolean; onToggle?: (v: boolean) => void; + onPress?: () => void; disabled?: boolean; }) { const { colors } = useAppTheme(); - return ( - + const isPressable = type === 'arrow' && !!onPress && !disabled; + const content = ( + <> @@ -126,8 +137,22 @@ function SettingRow({ {label} {sublabel && {sublabel}} - {type === 'arrow' && } + {type === 'arrow' && } {type === 'toggle' && } + + ); + + if (isPressable) { + return ( + + {content} + + ); + } + + return ( + + {content} ); } @@ -135,13 +160,46 @@ function SettingRow({ // ─── Main ───────────────────────────────────────────────────────────────────── export default function SettingsScreen() { const { colors, mode, setMode, isLoading } = useAppTheme(); + const { airport, airportCode, setAirportCode, isLoading: airportLoading } = useAirport(); + const [airportModalOpen, setAirportModalOpen] = useState(false); + const [airportInput, setAirportInput] = useState(airportCode); + + const openAirportModal = () => { + setAirportInput(airportCode); + setAirportModalOpen(true); + }; + + const closeAirportModal = () => { + setAirportModalOpen(false); + setAirportInput(airportCode); + }; + + const saveAirport = async () => { + const normalized = normalizeAirportCode(airportInput); + if (!isValidAirportCode(normalized)) { + Alert.alert('Codice non valido', 'Inserisci un codice IATA di 3 lettere, per esempio PSA o FCO.'); + return; + } + + try { + await setAirportCode(normalized); + setAirportModalOpen(false); + Alert.alert( + 'Aeroporto aggiornato', + 'Voli, timeline, widget e notifiche useranno il nuovo aeroporto.', + ); + } catch { + Alert.alert('Errore', 'Non sono riuscito a salvare il nuovo aeroporto.'); + } + }; return ( - + <> + {/* Header */} @@ -149,7 +207,7 @@ export default function SettingsScreen() { Impostazioni - AeroStaff Pro Β· v1.0 + AeroStaff Pro Β· v1.1.0 @@ -187,7 +245,14 @@ export default function SettingsScreen() { {/* ── Sezione Aeroporto ── */} AEROPORTO - + @@ -203,27 +268,89 @@ export default function SettingsScreen() { {/* ── Info app ── */} APP - + - {/* ── TEST NOTIFICA (rimuovere dopo test) ── */} - { - const { status } = await Notifications.requestPermissionsAsync(); - if (status !== 'granted') { Alert.alert('Permesso negato'); return; } - await Notifications.scheduleNotificationAsync({ - content: { title: '✈️ Test AeroStaff', body: 'Notifica funzionante!', sound: true }, - trigger: null, - }); - Alert.alert('Inviata!', 'Controlla la barra delle notifiche'); - }} + + + + - πŸ”” Invia notifica test - + + + + Cambia aeroporto + + Inserisci un codice IATA di 3 lettere. Il cambio aggiorna voli, timeline, widget e notifiche. + + + Codice aeroporto + setAirportInput(normalizeAirportCode(text))} + placeholder="PSA" + placeholderTextColor={colors.textMuted} + autoCapitalize="characters" + autoCorrect={false} + maxLength={3} + style={[styles.modalInput, { color: colors.text, borderColor: colors.border, backgroundColor: colors.bg }]} + /> + + Scelta rapida + + {AIRPORT_PRESETS.map(item => { + const selected = airportInput === item.code; + return ( + setAirportInput(item.code)} + activeOpacity={0.85} + > + + {item.code} + + + {item.city} + + + ); + })} + - - + + + Annulla + + + Salva + + + + + + ); } @@ -276,6 +403,39 @@ const styles = StyleSheet.create({ rowText: { flex: 1 }, rowLabel:{ fontSize: 14, fontWeight: '600' }, rowSub: { fontSize: 12, marginTop: 1 }, - testBtn: { borderRadius: 14, padding: 16, alignItems: 'center', marginBottom: 12 }, - testBtnTxt: { fontSize: 15, fontWeight: '700', color: '#fff' }, + modalOverlay: { flex: 1, justifyContent: 'center', padding: 20, backgroundColor: 'rgba(15,23,42,0.48)' }, + modalCard: { + borderRadius: 20, + padding: 20, + borderWidth: 1, + shadowColor: '#000', + shadowOpacity: 0.16, + shadowRadius: 16, + elevation: 12, + }, + modalTitle: { fontSize: 18, fontWeight: '800', marginBottom: 8 }, + modalCopy: { fontSize: 13, lineHeight: 20, marginBottom: 16 }, + modalLabel: { fontSize: 11, fontWeight: '700', letterSpacing: 0.8, marginBottom: 8 }, + modalInput: { + borderWidth: 1, + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 16, + fontWeight: '700', + marginBottom: 16, + }, + airportChipWrap: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginBottom: 18 }, + airportChip: { + minWidth: 88, + borderRadius: 14, + paddingHorizontal: 12, + paddingVertical: 10, + borderWidth: 1, + }, + airportChipCode: { fontSize: 14, fontWeight: '800' }, + airportChipName: { fontSize: 11, marginTop: 2 }, + modalActions: { flexDirection: 'row', gap: 10 }, + modalBtn: { flex: 1, borderRadius: 14, paddingVertical: 14, alignItems: 'center' }, + modalBtnTxt: { fontSize: 14, fontWeight: '700' }, }); diff --git a/src/screens/TraveldocScreen.tsx b/src/screens/TraveldocScreen.tsx index 494e335..7b80a8f 100644 --- a/src/screens/TraveldocScreen.tsx +++ b/src/screens/TraveldocScreen.tsx @@ -1,5 +1,5 @@ // src/screens/TraveldocScreen.tsx -import React, { useState, useMemo } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { StyleSheet, View, Text, ActivityIndicator } from 'react-native'; import { WebView } from 'react-native-webview'; import { useAppTheme } from '../context/ThemeContext'; @@ -7,6 +7,14 @@ import { useAppTheme } from '../context/ThemeContext'; export default function TraveldocScreen() { const { colors } = useAppTheme(); const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(false); + + // Timeout: hide spinner after 15s even if WebView never fires onLoadEnd + useEffect(() => { + if (!loading) return; + const timer = setTimeout(() => { setLoading(false); setLoadError(true); }, 15_000); + return () => clearTimeout(timer); + }, [loading]); return ( @@ -23,10 +31,18 @@ export default function TraveldocScreen() { Caricamento TravelDoc… )} + {loadError && !loading && ( + + + Caricamento lento. Verifica la connessione internet. + + + )} setLoading(false)} + onLoadEnd={() => { setLoading(false); setLoadError(false); }} + onError={() => { setLoading(false); setLoadError(true); }} javaScriptEnabled domStorageEnabled /> diff --git a/src/utils/airportSettings.ts b/src/utils/airportSettings.ts new file mode 100644 index 0000000..3294454 --- /dev/null +++ b/src/utils/airportSettings.ts @@ -0,0 +1,88 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export type AirportPreset = { + code: string; + name: string; + city: string; + icao?: string; +}; + +export type AirportInfo = AirportPreset & { + isCustom: boolean; +}; + +export const AIRPORT_STORAGE_KEY = 'aerostaff_airport_code_v1'; +export const DEFAULT_AIRPORT_CODE = 'PSA'; + +export const AIRPORT_PRESETS: AirportPreset[] = [ + { code: 'PSA', name: 'Pisa International', city: 'Pisa', icao: 'LIRP' }, + { code: 'FCO', name: 'Rome Fiumicino', city: 'Rome', icao: 'LIRF' }, + { code: 'CIA', name: 'Rome Ciampino', city: 'Rome', icao: 'LIRA' }, + { code: 'MXP', name: 'Milan Malpensa', city: 'Milan', icao: 'LIMC' }, + { code: 'LIN', name: 'Milan Linate', city: 'Milan', icao: 'LIML' }, + { code: 'BGY', name: 'Bergamo Orio al Serio', city: 'Bergamo', icao: 'LIME' }, + { code: 'BLQ', name: 'Bologna Guglielmo Marconi', city: 'Bologna', icao: 'LIPE' }, + { code: 'VCE', name: 'Venice Marco Polo', city: 'Venice', icao: 'LIPZ' }, + { code: 'FLR', name: 'Florence Peretola', city: 'Florence', icao: 'LIRQ' }, + { code: 'NAP', name: 'Naples International', city: 'Naples', icao: 'LIRN' }, + { code: 'CTA', name: 'Catania Fontanarossa', city: 'Catania', icao: 'LICC' }, + { code: 'PMO', name: 'Palermo Falcone Borsellino', city: 'Palermo', icao: 'LICJ' }, +]; + +const AIRPORT_MAP = Object.fromEntries( + AIRPORT_PRESETS.map(airport => [airport.code, airport] as const), +) as Record; + +export function normalizeAirportCode(value: string | null | undefined): string { + return (value ?? '').replace(/[^a-zA-Z]/g, '').slice(0, 3).toUpperCase(); +} + +export function isValidAirportCode(value: string | null | undefined): boolean { + return /^[A-Z]{3}$/.test(normalizeAirportCode(value)); +} + +export function getAirportInfo(code: string | null | undefined): AirportInfo { + const normalized = isValidAirportCode(code) ? normalizeAirportCode(code) : DEFAULT_AIRPORT_CODE; + const preset = AIRPORT_MAP[normalized]; + if (preset) return { ...preset, isCustom: false }; + return { + code: normalized, + name: 'Aeroporto personalizzato', + city: normalized, + isCustom: true, + }; +} + +export function formatAirportSettingLabel(code: string | null | undefined): string { + const airport = getAirportInfo(code); + return airport.isCustom + ? `${airport.code} Β· Aeroporto personalizzato` + : `${airport.code} Β· ${airport.name}`; +} + +export function formatAirportHeader(code: string | null | undefined): string { + const airport = getAirportInfo(code); + if (airport.isCustom) return `Aeroporto selezionato Β· ${airport.code}`; + return airport.icao + ? `${airport.name} Β· ${airport.code} / ${airport.icao}` + : `${airport.name} Β· ${airport.code}`; +} + +export function buildFr24ScheduleUrl(code: string | null | undefined): string { + const normalized = isValidAirportCode(code) ? normalizeAirportCode(code) : DEFAULT_AIRPORT_CODE; + return `https://api.flightradar24.com/common/v1/airport.json?code=${normalized.toLowerCase()}&plugin[]=schedule&page=1&limit=100`; +} + +export async function getStoredAirportCode(): Promise { + const stored = await AsyncStorage.getItem(AIRPORT_STORAGE_KEY); + return isValidAirportCode(stored) ? normalizeAirportCode(stored) : DEFAULT_AIRPORT_CODE; +} + +export async function setStoredAirportCode(code: string): Promise { + const normalized = normalizeAirportCode(code); + if (!isValidAirportCode(normalized)) { + throw new Error('INVALID_AIRPORT_CODE'); + } + await AsyncStorage.setItem(AIRPORT_STORAGE_KEY, normalized); + return normalized; +} diff --git a/src/utils/autoNotifications.ts b/src/utils/autoNotifications.ts index 53384e6..4c44518 100644 --- a/src/utils/autoNotifications.ts +++ b/src/utils/autoNotifications.ts @@ -1,7 +1,8 @@ import * as Calendar from 'expo-calendar'; import * as Notifications from 'expo-notifications'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { ALLOWED_AIRLINES, getAirlineOps } from './airlineOps'; +import { getAirlineOps } from './airlineOps'; +import { fetchAirportScheduleRaw } from './fr24api'; const NOTIF_IDS_KEY = 'aerostaff_notif_ids_v1'; const LAST_SCHEDULE_KEY = 'aerostaff_notif_last_schedule'; @@ -47,14 +48,8 @@ export async function autoScheduleNotifications(): Promise { const shiftStart = new Date(shiftEvent.startDate).getTime() / 1000; const shiftEnd = new Date(shiftEvent.endDate).getTime() / 1000; - // Fetch departures and arrivals from FR24 - const res = await fetch('https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100', { - headers: { 'User-Agent': 'Mozilla/5.0' }, - }); - const json = await res.json(); - const filterAirlines = (data: any[]) => data.filter((i: any) => ALLOWED_AIRLINES.some(k => (i.flight?.airline?.name || '').toLowerCase().includes(k))); - const allDepartures = filterAirlines(json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []); - const allArrivals = filterAirlines(json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []); + // Fetch departures and arrivals from FR24 using the selected airport + const { departures: allDepartures, arrivals: allArrivals } = await fetchAirportScheduleRaw(); // Filter departures during shift const shiftDepartures = allDepartures.filter((item: any) => { diff --git a/src/utils/dateFormat.ts b/src/utils/dateFormat.ts new file mode 100644 index 0000000..9e09312 --- /dev/null +++ b/src/utils/dateFormat.ts @@ -0,0 +1,34 @@ +/** + * Centralised date/time formatters β€” Italian locale. + * Avoids scattered toLocaleTimeString calls with inconsistent options. + */ + +const TIME_OPTIONS: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' }; +const LOCALE = 'it-IT'; + +/** Format a Date or unix-seconds timestamp to "HH:MM". */ +export function fmtTime(input: Date | number): string { + const date = typeof input === 'number' + ? new Date(input > 1e12 ? input : input * 1000) // auto-detect seconds vs ms + : input; + return date.toLocaleTimeString(LOCALE, TIME_OPTIONS); +} + +/** Format a unix-seconds departure minus an offset in minutes to "HH:MM". */ +export function fmtOffset(departureSec: number, offsetMinutes: number): string { + return fmtTime(departureSec - offsetMinutes * 60); +} + +/** Italian month names (0-indexed). */ +export const MONTHS_IT = [ + 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', + 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre', +] as const; + +/** Format ISO date "YYYY-MM-DD" β†’ "Lun 05/03". */ +export function fmtDateShort(iso: string): string { + const [y, m, d] = iso.split('-'); + const dt = new Date(+y, +m - 1, +d); + const dayName = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'][dt.getDay()]; + return `${dayName} ${d}/${m}`; +} diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts new file mode 100644 index 0000000..b230cb4 --- /dev/null +++ b/src/utils/errorHandler.ts @@ -0,0 +1,38 @@ +import { Alert } from 'react-native'; + +type ErrorContext = + | 'calendar' + | 'ocr' + | 'notification' + | 'storage' + | 'network' + | 'pin' + | 'flight' + | 'import'; + +const CONTEXT_LABELS: Record = { + calendar: 'Calendario', + ocr: 'Riconoscimento testo', + notification: 'Notifiche', + storage: 'Salvataggio dati', + network: 'Connessione', + pin: 'PIN', + flight: 'Dati volo', + import: 'Importazione', +}; + +/** + * Shared error handler: logs to console AND shows user alert. + * Use for all catch blocks to ensure consistent error reporting. + */ +export function handleError(error: unknown, context: ErrorContext, silent = false): void { + const message = error instanceof Error ? error.message : String(error); + console.error(`[${context}]`, message); + + if (!silent) { + Alert.alert( + `Errore ${CONTEXT_LABELS[context]}`, + message || 'Si Γ¨ verificato un errore imprevisto.', + ); + } +} diff --git a/src/utils/fr24api.ts b/src/utils/fr24api.ts index eb2f527..a560d89 100644 --- a/src/utils/fr24api.ts +++ b/src/utils/fr24api.ts @@ -1,37 +1,66 @@ import { ALLOWED_AIRLINES } from './airlineOps'; +import { + buildFr24ScheduleUrl, + getAirportInfo, + getStoredAirportCode, + isValidAirportCode, + normalizeAirportCode, + type AirportInfo, +} from './airportSettings'; -const FR24_URL = 'https://api.flightradar24.com/common/v1/airport.json?code=psa&plugin[]=schedule&page=1&limit=100'; const FETCH_TIMEOUT = 10000; // 10 seconds export type FR24Schedule = { arrivals: any[]; departures: any[]; + airportCode: string; + airport: AirportInfo; }; +export type FR24ScheduleRaw = { + allArrivals: any[]; + allDepartures: any[]; + arrivals: any[]; + departures: any[]; + airportCode: string; + airport: AirportInfo; +}; + +function filterAirlines(data: any[]) { + return data.filter(item => + ALLOWED_AIRLINES.some(key => (item.flight?.airline?.name || '').toLowerCase().includes(key)), + ); +} + +async function resolveAirportCode(code?: string): Promise { + const normalized = normalizeAirportCode(code); + return isValidAirportCode(normalized) ? normalized : getStoredAirportCode(); +} + /** - * Fetch PSA airport schedule from FlightRadar24, filtered by allowed airlines. + * Fetch airport schedule from FlightRadar24, filtered by allowed airlines. * Includes a 10s timeout to prevent UI blocking. */ -export async function fetchPSASchedule(): Promise { +export async function fetchAirportSchedule(code?: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT); try { - const res = await fetch(FR24_URL, { + 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 filterAirlines = (data: any[]) => - data.filter(i => ALLOWED_AIRLINES.some(k => (i.flight?.airline?.name || '').toLowerCase().includes(k))); - const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []; const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; return { arrivals: filterAirlines(allArrivals), departures: filterAirlines(allDepartures), + airportCode, + airport: getAirportInfo(airportCode), }; } finally { clearTimeout(timer); @@ -42,20 +71,18 @@ export async function fetchPSASchedule(): Promise { * Fetch raw (unfiltered) schedule β€” needed when callers also use non-allowed airline data * (e.g. inbound arrival map by registration). */ -export async function fetchPSAScheduleRaw(): Promise<{ allArrivals: any[]; allDepartures: any[]; arrivals: any[]; departures: any[] }> { +export async function fetchAirportScheduleRaw(code?: string): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT); try { - const res = await fetch(FR24_URL, { + 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 filterAirlines = (data: any[]) => - data.filter(i => ALLOWED_AIRLINES.some(k => (i.flight?.airline?.name || '').toLowerCase().includes(k))); - const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []; const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; @@ -64,8 +91,14 @@ export async function fetchPSAScheduleRaw(): Promise<{ allArrivals: any[]; allDe allDepartures, arrivals: filterAirlines(allArrivals), departures: filterAirlines(allDepartures), + airportCode, + airport: getAirportInfo(airportCode), }; } finally { clearTimeout(timer); } } + +// Legacy aliases kept to avoid breaking older imports. +export const fetchPSASchedule = fetchAirportSchedule; +export const fetchPSAScheduleRaw = fetchAirportScheduleRaw; diff --git a/src/utils/shiftCalendar.ts b/src/utils/shiftCalendar.ts new file mode 100644 index 0000000..0eb0c9a --- /dev/null +++ b/src/utils/shiftCalendar.ts @@ -0,0 +1,175 @@ +import * as Calendar from 'expo-calendar'; + +export type ShiftEventTitles = { + work: string; + rest: string; +}; + +export type RestEventTiming = { + startHour: number; + startMinute: number; + endHour: number; + endMinute: number; + allDay?: boolean; +}; + +export type ShiftReplacement = { + date: string; + type: 'work' | 'rest'; + startTime?: string; + endTime?: string; +}; + +type ReplaceShiftForDateArgs = ShiftReplacement & { + calendarId: string; + titles?: ShiftEventTitles; + restTiming?: RestEventTiming; +}; + +type ReplaceShiftsForRangeArgs = { + calendarId: string; + shifts: ShiftReplacement[]; + titles?: ShiftEventTitles; + restTiming?: RestEventTiming; +}; + +const DEFAULT_TITLES: ShiftEventTitles = { + work: 'Lavoro', + rest: 'Riposo', +}; + +const DEFAULT_REST_TIMING: RestEventTiming = { + startHour: 0, + startMinute: 0, + endHour: 23, + endMinute: 59, + allDay: false, +}; + +function parseIsoDate(date: string): { year: number; month: number; day: number } { + const [year, month, day] = date.split('-').map(Number); + return { year, month, day }; +} + +function parseTime(time: string): { hour: number; minute: number } { + const [hour, minute] = time.split(':').map(Number); + return { hour, minute }; +} + +function isShiftEventTitle(title?: string | null) { + return (title || '').includes('Lavoro') || (title || '').includes('Riposo'); +} + +async function createShiftEvent( + calendarId: string, + shift: ShiftReplacement, + titles: ShiftEventTitles, + restTiming: RestEventTiming, +): Promise { + const { year, month, day } = parseIsoDate(shift.date); + + if (shift.type === 'work') { + if (!shift.startTime || !shift.endTime) return false; + + const startTime = parseTime(shift.startTime); + const endTime = parseTime(shift.endTime); + const startDate = new Date(year, month - 1, day, startTime.hour, startTime.minute, 0, 0); + const endDate = new Date(year, month - 1, day, endTime.hour, endTime.minute, 0, 0); + if (endDate <= startDate) endDate.setDate(endDate.getDate() + 1); + + await Calendar.createEventAsync(calendarId, { + title: titles.work, + startDate, + endDate, + timeZone: 'Europe/Rome', + }); + return true; + } + + const startDate = new Date(year, month - 1, day, restTiming.startHour, restTiming.startMinute, 0, 0); + const endDate = new Date(year, month - 1, day, restTiming.endHour, restTiming.endMinute, 0, 0); + if (endDate <= startDate) endDate.setDate(endDate.getDate() + 1); + + await Calendar.createEventAsync(calendarId, { + title: titles.rest, + startDate, + endDate, + allDay: restTiming.allDay, + timeZone: 'Europe/Rome', + }); + return true; +} + +export async function getWritableCalendarId(): Promise { + const calendars = await Calendar.getCalendarsAsync(Calendar.EntityTypes.EVENT); + const calendar = + calendars.find(item => item.allowsModifications && item.isPrimary) + || calendars.find(item => item.allowsModifications); + + return calendar?.id ?? null; +} + +export async function deleteShiftEventsInRange( + calendarId: string, + start: Date, + end: Date, +): Promise { + const events = await Calendar.getEventsAsync([calendarId], start, end); + const shiftEvents = events.filter(event => isShiftEventTitle(event.title)); + + await Promise.all( + shiftEvents.map(event => Calendar.deleteEventAsync(event.id).catch(() => {})), + ); + + return shiftEvents.length; +} + +export async function replaceShiftForDate({ + calendarId, + date, + type, + startTime, + endTime, + titles = DEFAULT_TITLES, + restTiming = DEFAULT_REST_TIMING, +}: ReplaceShiftForDateArgs): Promise { + const { year, month, day } = parseIsoDate(date); + const dayStart = new Date(year, month - 1, day, 0, 0, 0, 0); + const dayEnd = new Date(year, month - 1, day, 23, 59, 59, 999); + + await deleteShiftEventsInRange(calendarId, dayStart, dayEnd); + + const created = await createShiftEvent( + calendarId, + { date, type, startTime, endTime }, + titles, + restTiming, + ); + + return created ? 1 : 0; +} + +export async function replaceShiftsForRange({ + calendarId, + shifts, + titles = DEFAULT_TITLES, + restTiming = DEFAULT_REST_TIMING, +}: ReplaceShiftsForRangeArgs): Promise { + if (shifts.length === 0) return 0; + + const sorted = [...shifts].sort((a, b) => a.date.localeCompare(b.date)); + const firstDate = parseIsoDate(sorted[0].date); + const lastDate = parseIsoDate(sorted[sorted.length - 1].date); + const rangeStart = new Date(firstDate.year, firstDate.month - 1, firstDate.day, 0, 0, 0, 0); + const rangeEnd = new Date(lastDate.year, lastDate.month - 1, lastDate.day, 23, 59, 59, 999); + + await deleteShiftEventsInRange(calendarId, rangeStart, rangeEnd); + + let createdCount = 0; + for (const shift of sorted) { + const created = await createShiftEvent(calendarId, shift, titles, restTiming); + if (created) createdCount += 1; + } + + return createdCount; +}