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;
+}