Initial commit
This commit is contained in:
65
utils/api.ts
Normal file
65
utils/api.ts
Normal 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
124
utils/authContext.tsx
Normal 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
87
utils/configProvider.tsx
Normal 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
91
utils/dateTime.ts
Normal 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
105
utils/documentUtils.tsx
Normal 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
42
utils/networkProvider.tsx
Normal 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
27
utils/version.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user