Initial commit

This commit is contained in:
2026-07-31 16:53:16 +02:00
commit e49d6f0e5b
67 changed files with 20163 additions and 0 deletions

65
utils/api.ts Normal file
View File

@@ -0,0 +1,65 @@
import axios from 'axios';
import * as SecureStore from 'expo-secure-store';
const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL;
export const KEY_TOKEN = 'auth_key';
// Create an Axios instance with default configuration
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
timeout: 10000, // 10 seconds timeout
});
// Export function to update base URL
export const setApiBaseUrl = (url: string) => {
if (url) {
api.defaults.baseURL = url;
console.log(`[API] Base URL updated to: ${url}`);
}
};
// Interceptor: Adds the token to EVERY request if it exists
api.interceptors.request.use(
async (config) => {
const token = await SecureStore.getItemAsync(KEY_TOKEN);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
console.log(`[API REQUEST] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Interceptor: Global error handling (e.g., expired token)
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response) {
const isLoginRequest = originalRequest?.url?.includes('/user/login');
if (!(error.response.status === 401 && isLoginRequest)) {
console.error('[API ERROR]', error.response.status, error.response.data);
}
// If we receive 401 (Unauthorized), we might want to force logout
if (error.response.status === 401) {
// TODO: Here you can add logic to redirect to login screen if needed
await SecureStore.deleteItemAsync(KEY_TOKEN);
}
} else {
console.error('[API NETWORK ERROR]', error.message);
}
return Promise.reject(error);
}
);
export default api;

124
utils/authContext.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react';
import { SplashScreen, useRouter, useSegments } from 'expo-router';
import { UserData } from '@/types/types';
import * as SecureStore from 'expo-secure-store';
import api, { KEY_TOKEN } from './api';
type AuthState = {
isAuthenticated: boolean;
isReady: boolean;
user: UserData | null;
logIn: (token: string, userData: UserData) => void;
logOut: () => void;
};
SplashScreen.preventAutoHideAsync();
export const AuthContext = createContext<AuthState>({
isAuthenticated: false,
isReady: false,
user: null,
logIn: () => { },
logOut: () => { },
});
export const useAuth = () => useContext(AuthContext);
export function AuthProvider({ children }: PropsWithChildren) {
const [isReady, setIsReady] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<UserData | null>(null);
const router = useRouter();
const segments = useSegments();
const logIn = async (token: string, userData: UserData) => {
try {
await SecureStore.setItemAsync(KEY_TOKEN, token);
setIsAuthenticated(true);
setUser(userData);
router.replace('/');
} catch (error) {
console.error('Errore durante il login:', error);
}
};
const logOut = async () => {
try {
await SecureStore.deleteItemAsync(KEY_TOKEN);
setIsAuthenticated(false);
setUser(null);
router.replace('/login');
} catch (error) {
console.error('Errore durante il logout:', error);
}
};
useEffect(() => {
const initApp = async () => {
try {
// Get saved Token from SecureStore
const savedToken = await SecureStore.getItemAsync(KEY_TOKEN);
if (savedToken) {
console.log("Token trovato:", savedToken);
// Call backend to verify token and fetch user data
// Note: api.ts already adds the Authorization header thanks to the interceptor (if configured to read from SecureStore)
// If your api.ts reads from AsyncStorage, make sure they are aligned, otherwise pass it manually here:
const response = await api.get("/user", {
headers: { Authorization: `Bearer ${savedToken}` }
});
const result = response.data;
console.log("Sessione valida, dati utente caricati:", result);
const loadedUser: UserData = {
firstName: result.nome,
lastName: result.cognome,
email: result.email,
isAdmin: result.isAdmin
};
setUser(loadedUser);
setIsAuthenticated(true);
} else {
console.log("Nessun token salvato.");
}
} catch (error: any) {
console.error('Errore inizializzazione (Token scaduto o Server down):', error.message);
// If the token is not valid, clear everything
await SecureStore.deleteItemAsync(KEY_TOKEN);
setIsAuthenticated(false);
setUser(null);
} finally {
setIsReady(true);
await SplashScreen.hideAsync();
}
};
initApp();
}, []);
// Route protection (optional, but recommended here or in the Layout)
useEffect(() => {
if (!isReady) return;
const inAuthGroup = segments[0] === '(protected)';
if (!isAuthenticated && inAuthGroup) {
router.replace('/login');
} else if (isAuthenticated && !inAuthGroup) {
router.replace('/');
}
}, [isReady, isAuthenticated, segments]);
return (
<AuthContext.Provider value={{ isReady, isAuthenticated, user, logIn, logOut }}>
{children}
</AuthContext.Provider>
);
}

87
utils/configProvider.tsx Normal file
View File

@@ -0,0 +1,87 @@
import React, { createContext, useState, useEffect, ReactNode } from 'react';
import { Linking, Platform } from 'react-native';
import Constants from 'expo-constants';
import axios from 'axios';
import LoadingScreen from '@/components/LoadingScreen';
import UpdateScreen from '@/components/UpdateScreen';
import { isUpdateAvailable } from '@/utils/version';
import { setApiBaseUrl } from './api';
interface ConfigContextProps {
children: ReactNode
};
// Context (useful if you want to trigger manual checks from inside the app in the future)
export const ConfigContext = createContext({});
const GW_API = process.env.EXPO_PUBLIC_GW_API_URL;
const GW_UUID = process.env.EXPO_PUBLIC_GW_UUID;
const GW_TOKEN = process.env.EXPO_PUBLIC_GW_API_TOKEN;
export const ConfigProvider = ({ children }: ConfigContextProps) => {
const [isChecking, setIsChecking] = useState(true);
const [needsUpdate, setNeedsUpdate] = useState(false);
const [updateUrl, setUpdateUrl] = useState('');
useEffect(() => {
const checkAppVersion = async () => {
try {
const apiUrl = `${GW_API}${GW_UUID}`;
const response = await axios.get(apiUrl, {
headers: { "x-access-tokens": GW_TOKEN }
});
// Update API URL: prioritize environment variable (override) over gateway response
setApiBaseUrl(process.env.EXPO_PUBLIC_API_URL || response.data.url);
const currentVersion = Constants.expoConfig?.version;
console.log("Versione attuale dell'app:", currentVersion);
const latestVersion = response.data.version;
console.log("Versione più recente disponibile:", latestVersion);
// Check if an update is needed
if (isUpdateAvailable(currentVersion, latestVersion)) {
setNeedsUpdate(true);
setUpdateUrl(Platform.OS === 'ios' ? response.data.app_url_ios : response.data.app_url_android);
}
} catch (error) {
console.error("Errore durante il controllo della versione:", error);
setNeedsUpdate(false);
} finally {
setIsChecking(false);
}
};
checkAppVersion();
}, []);
const handleUpdate = async () => {
if (updateUrl) {
Linking.openURL(updateUrl);
}
};
// Loading state
if (isChecking) {
return (
<LoadingScreen />
);
}
// Update state: blocks children rendering
if (needsUpdate) {
return (
<UpdateScreen onUpdate={handleUpdate} />
);
}
// Version is up to date
return (
<ConfigContext.Provider value={{ isChecking, needsUpdate }}>
{children}
</ConfigContext.Provider>
);
};

91
utils/dateTime.ts Normal file
View File

@@ -0,0 +1,91 @@
import { DateType } from "react-native-ui-datepicker";
/**
* Transforms "YYYY-MM-DD" to "DD/MM/YYYY"
* @param dateStr string in ISO date format "YYYY-MM-DD"
* @returns formatted string "DD/MM/YYYY"
*/
export const formatDate = (dateStr: string | null | undefined): string => {
if (!dateStr) return '';
const [year, month, day] = dateStr.split('-');
return `${day}/${month}/${year}`;
};
/**
* Transforms time from "HH:MM:SS" to "HH:MM"
* @param timeStr string in time format "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS"
* @returns formatted string "HH:MM"
*/
export const formatTime = (timeStr: string | null | undefined): string => {
if (!timeStr) return '';
// Handle both "HH:MM:SS" and "YYYY-MM-DD HH:MM:SS" formats
const timePart = timeStr.includes(' ') ? timeStr.split(' ')[1] : timeStr;
const [hours, minutes] = timePart.split(':');
return `${hours}:${minutes}`;
};
/**
* Formats a date for use with a date picker, normalizing it to midnight
* @param d Date in DateType format
* @returns string in "YYYY-MM-DD" format or null if input is null/undefined
*/
export const formatPickerDate = (d: DateType | null | undefined) => {
if (!d) return null;
const date = new Date(d as string | number | Date);
const normalized = new Date(date.getFullYear(), date.getMonth(), date.getDate());
const yyyy = normalized.getFullYear();
const mm = String(normalized.getMonth() + 1).padStart(2, "0");
const dd = String(normalized.getDate()).padStart(2, "0");
return `${yyyy}-${mm}-${dd}`;
}
/**
* Transforms a timestamp into a string "DD/MM/YYYY HH:mm:ss"
* @param timestamp string or Date object
* @returns formatted string or empty string if input is invalid
*/
export const formatTimestamp = (timestamp: string | Date | null | undefined): string => {
if (!timestamp) return '';
const date = timestamp instanceof Date ? timestamp : new Date(timestamp);
if (isNaN(date.getTime())) return '';
const dd = String(date.getDate()).padStart(2, '0');
const mm = String(date.getMonth() + 1).padStart(2, '0'); // months from 0 to 11
const yyyy = date.getFullYear();
const hh = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${dd}/${mm}/${yyyy} ${hh}:${min}:${ss}`;
};
/**
* Converts an ISO timestamp to a Date object
* @param dateStr string in ISO date format
* @returns corresponding Date object
*/
export const parseTimestamp = (dateStr: string | undefined | null): Date => {
if (!dateStr) return new Date();
const date = new Date(dateStr);
if (isNaN(date.getTime())) return new Date();
return date;
};
export const parseSecondsToTime = (totalSeconds: number | null | undefined): string => {
if (totalSeconds == null || isNaN(totalSeconds)) return '';
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const hh = String(hours);
const mm = String(minutes).padStart(2, '0');
const ss = String(seconds).padStart(2, '0');
return `${hh}h`;
}

105
utils/documentUtils.tsx Normal file
View File

@@ -0,0 +1,105 @@
import api from '@/utils/api';
import { Directory, File, Paths } from 'expo-file-system';
import * as Sharing from 'expo-sharing';
/**
* Handles upload of a document through the server using FormData
* @param file File to upload (must have at least the 'uri' property)
* @param siteId ID of the site to associate the document with (null for general register)
* @param customTitle Custom title for the document (optional)
*/
export const uploadDocument = async (
file: any,
siteId: number | null,
customTitle?: string
): Promise<void> => {
if (!file || !file.uri) {
throw new Error("File non valido per l'upload.");
}
try {
const formData = new FormData();
formData.append('file', {
uri: file.uri,
name: customTitle || file.name,
type: file.mimeType
} as any);
if (siteId !== null) {
formData.append('siteId', siteId.toString());
}
if (customTitle) {
formData.append('customTitle', customTitle.trim());
}
const response = await api.post('/attachment/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
console.log("Risposta server:", response.data);
if (response.data?.status === 'error') {
throw new Error(response.data.message || "Errore sconosciuto dal server");
}
} catch (error: any) {
console.error("Errore durante l'upload del documento:", error);
if (error.response) {
const serverMessage = error.response.data?.message || error.message;
throw new Error(`Errore Server (${error.response.status}): ${serverMessage}`);
} else if (error.request) {
throw new Error("Il server non risponde. Controlla la connessione.");
} else {
throw error;
}
}
};
/**
* Download and share a document (expo-sharing)
* @param attachmentId ID or relative URL of the document
* @param fileName Name to save the file as
* @param fileUrl Full URL of the file to download
*/
export const downloadAndShareDocument = async (
mimetype: string,
fileName: string,
fileUrl: string
): Promise<void> => {
try {
// TODO: Download based on expo-sharing - some mime types may not be supported
if (!fileUrl || !fileName) {
throw new Error("Parametri mancanti per il download del documento.");
}
const destination = new Directory(Paths.cache, 'documents');
destination.exists ? destination.delete() : null;
destination.create({ overwrite: true });
const tmpFile = await File.downloadFileAsync(fileUrl, destination);
console.log("File temporaneo scaricato in:", tmpFile.uri);
const outFile = new File(destination, fileName);
await tmpFile.move(outFile);
console.log("File spostato in:", outFile.uri);
console.log("File type:", mimetype);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(outFile.uri, {
mimeType: mimetype,
dialogTitle: `Scarica ${fileName}`,
UTI: 'public.item'
});
} else {
throw new Error("Condivisione non supportata su questo dispositivo.");
}
} catch (error) {
console.error("Download Error:", error);
throw error;
}
};

42
utils/networkProvider.tsx Normal file
View File

@@ -0,0 +1,42 @@
import React, { useState, useEffect, ReactNode } from 'react';
import NetInfo, { useNetInfo } from '@react-native-community/netinfo';
import OfflineScreen from '@/components/OfflineScreen';
interface NetworkProviderProps {
children: ReactNode;
}
export const NetworkProvider = ({ children }: NetworkProviderProps) => {
const netInfo = useNetInfo();
const [isOffline, setIsOffline] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
useEffect(() => {
if (netInfo.isConnected === false) {
setIsOffline(true);
} else {
setIsOffline(false);
}
}, [netInfo.isConnected]);
// Manual Retry Handler
const handleManualRetry = async () => {
setIsRetrying(true);
const state = await NetInfo.fetch();
setTimeout(() => {
setIsOffline(state.isConnected === false);
setIsRetrying(false);
}, 1000);
};
if (isOffline) {
return (
<OfflineScreen
onRetry={handleManualRetry}
isRetrying={isRetrying}
/>
);
}
return <>{children}</>;
};

27
utils/version.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* Compare two version strings.
* Returns true if 'latest' is greater than 'current' (an update is available).
*/
export const isUpdateAvailable = (currentVersion: string | undefined, latestVersion: string) => {
if (!currentVersion || !latestVersion) return false;
// Split strings into an array of numbers: "1.2.10" -> [1, 2, 10]
const currentParts = currentVersion.split('.').map(Number);
const latestParts = latestVersion.split('.').map(Number);
const maxLength = Math.max(currentParts.length, latestParts.length);
for (let i = 0; i < maxLength; i++) {
// If a part is missing, we consider it as 0 (e.g. "1.0" -> [1, 0, 0])
const current = currentParts[i] || 0;
const latest = latestParts[i] || 0;
if (current < latest) {
return true; // It needs an update
}
if (current > latest) {
return false;
}
}
return false;
};