293 lines
13 KiB
TypeScript
293 lines
13 KiB
TypeScript
import { ChevronLeft } from 'lucide-react-native';
|
|
import React, { useState, useEffect } from 'react';
|
|
import { View, Text, TouchableOpacity, TextInput, KeyboardAvoidingView, ScrollView, Platform, Dimensions, ActivityIndicator } from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { useRouter } from 'expo-router';
|
|
import { StatusBar } from 'expo-status-bar';
|
|
import PlaceFilter from '@/components/PlaceFilter';
|
|
import { Place } from '@/types/types';
|
|
import api from '@/utils/api';
|
|
import { useAlert } from '@/components/AlertComponent';
|
|
import RemovablePhotoTile from '@/components/RemovablePhotoTile';
|
|
import CameraAddTile from '@/components/CameraAddTile';
|
|
import * as ImagePicker from 'expo-image-picker';
|
|
|
|
export default function AddJournalScreen() {
|
|
const router = useRouter();
|
|
const alert = useAlert();
|
|
const [places, setPlaces] = useState<Place[]>([]);
|
|
const [selectedPlaceId, setSelectedPlaceId] = useState<any>(null);
|
|
const [description, setDescription] = useState('');
|
|
const [photos, setPhotos] = useState<any[]>([]);
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [uploadProgress, setUploadProgress] = useState<{ current: number, total: number } | null>(null);
|
|
|
|
// Dynamic calculation of grid items (4 items per row)
|
|
const windowWidth = Dimensions.get('window').width;
|
|
const padding = 24;
|
|
const gap = 12;
|
|
const itemsPerRow = 4;
|
|
const itemSize = (windowWidth - (padding * 2) - (gap * (itemsPerRow - 1))) / itemsPerRow;
|
|
|
|
// Function to pick images from the gallery
|
|
const pickFromGallery = async () => {
|
|
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
|
|
if (permissionResult.granted === false) {
|
|
alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla galleria per caricare foto.');
|
|
return;
|
|
}
|
|
|
|
const limit = 50 - photos.length;
|
|
if (limit <= 0) return;
|
|
|
|
try {
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|
mediaTypes: ['images'], // Only images
|
|
allowsMultipleSelection: true,
|
|
selectionLimit: limit,
|
|
quality: 0.8, // Apply light compression and permanently fix the correct orientation
|
|
});
|
|
|
|
if (!result.canceled && result.assets) {
|
|
setPhotos(prev => [...prev, ...result.assets]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Errore nella galleria:', error);
|
|
alert.showAlert('error', 'Errore', 'Impossibile accedere alla galleria.');
|
|
}
|
|
};
|
|
|
|
// Function to take a photo using the camera
|
|
const takePhoto = async () => {
|
|
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
|
|
|
if (permissionResult.granted === false) {
|
|
alert.showAlert('error', 'Permessi Negati', 'È necessario consentire l\'accesso alla fotocamera per scattare foto.');
|
|
return;
|
|
}
|
|
|
|
if (photos.length >= 50) return;
|
|
|
|
try {
|
|
const result = await ImagePicker.launchCameraAsync({
|
|
mediaTypes: ['images'],
|
|
quality: 0.8, // Apply light compression and permanently fix the correct orientation
|
|
});
|
|
|
|
if (!result.canceled && result.assets && result.assets.length > 0) {
|
|
setPhotos(prev => [...prev, result.assets[0]]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Errore nella fotocamera:', error);
|
|
alert.showAlert('error', 'Errore', 'Impossibile accedere alla fotocamera.');
|
|
}
|
|
};
|
|
|
|
const removePhoto = (index: number) => {
|
|
setPhotos(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!selectedPlaceId) return;
|
|
|
|
setIsSubmitting(true);
|
|
setUploadProgress(null);
|
|
|
|
try {
|
|
// Create record in the database first
|
|
const params = {
|
|
place: selectedPlaceId,
|
|
description: description,
|
|
n_files: photos.length
|
|
};
|
|
|
|
const response = await api.post('/journal/add', params);
|
|
|
|
if (response.data?.success) {
|
|
const journalId = response.data.id;
|
|
|
|
// Sequential asynchronous upload
|
|
if (photos.length > 0) {
|
|
setUploadProgress({ current: 0, total: photos.length });
|
|
|
|
for (let i = 0; i < photos.length; i++) {
|
|
const file = photos[i];
|
|
let formData = new FormData();
|
|
|
|
const fileName = file.fileName || file.uri.split('/').pop() || `photo_${i}.jpg`;
|
|
const fileType = file.mimeType || 'image/jpeg';
|
|
const fileUri = Platform.OS === 'android' ? file.uri : file.uri.replace('file://', '');
|
|
|
|
formData.append("files", {
|
|
name: fileName,
|
|
type: fileType,
|
|
uri: fileUri
|
|
} as any);
|
|
|
|
formData.append('model_classname', 'JournalUpdate');
|
|
formData.append('model_id', journalId);
|
|
formData.append('method', 'put');
|
|
formData.append('name', fileName);
|
|
formData.append('type', fileType);
|
|
|
|
await api.post('/journal/upload', formData, {
|
|
headers: { 'Content-Type': 'multipart/form-data' }
|
|
});
|
|
|
|
setUploadProgress({ current: i + 1, total: photos.length });
|
|
}
|
|
}
|
|
|
|
alert.showAlert('success', 'Ottimo Lavoro', 'Aggiornamento caricato con successo!');
|
|
router.replace('/(protected)/journal');
|
|
} else {
|
|
alert.showAlert('error', 'Errore', response.data?.message || 'Impossibile creare l\'avanzamento.');
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Errore durante il salvataggio:', error);
|
|
alert.showAlert('error', 'Errore di connessione', 'Verifica la tua connessione e riprova.');
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
setUploadProgress(null);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchPlaces();
|
|
}, []);
|
|
|
|
const fetchPlaces = async () => {
|
|
try {
|
|
// This endpoint returns the UUID of the construction site necessary for saving
|
|
const response = await api.get('/journal/get-places');
|
|
if (response.data?.success) {
|
|
const mappedPlaces = response.data.places.map((p: any) => ({
|
|
id: p.value, // value = UUID
|
|
label: p.label,
|
|
code: ''
|
|
}));
|
|
setPlaces(mappedPlaces);
|
|
}
|
|
} catch (error) {
|
|
console.error('Errore nel recupero dei cantieri:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<View className="flex-1 bg-gray-50">
|
|
<StatusBar style="dark" />
|
|
{/* Header */}
|
|
<View className="bg-white px-4 pb-6 shadow-sm border-b border-gray-100">
|
|
<SafeAreaView edges={['top']} className='pt-5'>
|
|
<View className='flex-row items-center gap-4 px-2'>
|
|
<TouchableOpacity onPress={() => router.back()} className="p-2 -ml-2 rounded-full active:bg-gray-100">
|
|
<ChevronLeft size={28} color="#4b5563" pointerEvents="none" />
|
|
</TouchableOpacity>
|
|
|
|
<Text className="text-xl font-bold text-gray-800 leading-tight uppercase flex-1 pr-4">
|
|
Nuovo Aggiornamento
|
|
</Text>
|
|
</View>
|
|
</SafeAreaView>
|
|
</View>
|
|
|
|
<KeyboardAvoidingView
|
|
behavior={Platform.OS === 'ios' ? 'padding' : 'padding'}
|
|
className="flex-1"
|
|
>
|
|
<ScrollView
|
|
contentContainerStyle={{ padding: 24, paddingBottom: 100 }}
|
|
showsVerticalScrollIndicator={false}
|
|
keyboardShouldPersistTaps="handled"
|
|
>
|
|
{/* Place Filter Component */}
|
|
<View className="border-b border-gray-200 mb-4">
|
|
<PlaceFilter
|
|
places={places}
|
|
selectedPlaceId={selectedPlaceId}
|
|
onPlaceSelect={setSelectedPlaceId}
|
|
textColor="text-primary-dark"
|
|
/>
|
|
</View>
|
|
|
|
{/* Description Area */}
|
|
<View className="pb-8 border-b border-gray-200 mb-6">
|
|
<Text className="text-lg font-bold text-primary-dark mb-3">Descrizione</Text>
|
|
<View className="bg-white rounded-2xl shadow-sm border border-gray-200">
|
|
<TextInput
|
|
className="p-4 text-base text-gray-800"
|
|
placeholder="(opzionale)"
|
|
placeholderTextColor="#9ca3af"
|
|
multiline
|
|
value={description}
|
|
onChangeText={setDescription}
|
|
style={{ minHeight: 80, textAlignVertical: 'top' }}
|
|
/>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Attachments Area */}
|
|
<View className="pb-8 border-b border-transparent mb-6">
|
|
<View className="flex-row items-center justify-between mb-4">
|
|
<Text className="text-lg font-bold text-gray-700">Allegati <Text className="text-sm font-normal text-gray-500">({photos.length}/50)</Text></Text>
|
|
{photos.length < 50 && (
|
|
<TouchableOpacity activeOpacity={0.7} onPress={pickFromGallery}>
|
|
<Text className="text-[#1071C2] font-bold text-base">Aggiungi</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
<View className="flex-row flex-wrap" style={{ gap: gap }}>
|
|
{photos.map((photo, index) => (
|
|
<RemovablePhotoTile
|
|
key={index}
|
|
uri={photo.uri}
|
|
size={itemSize}
|
|
onRemove={() => removePhoto(index)}
|
|
/>
|
|
))}
|
|
{photos.length < 50 && (
|
|
<CameraAddTile
|
|
size={itemSize}
|
|
onPress={takePhoto}
|
|
/>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
</ScrollView>
|
|
|
|
{/* Save Button */}
|
|
<View className="px-6 pt-2 pb-8">
|
|
<TouchableOpacity
|
|
className={`w-full py-4 rounded-[2rem] shadow-sm active:scale-[0.98] ${selectedPlaceId ? 'bg-[#1071C2]' : 'bg-gray-300'}`}
|
|
disabled={!selectedPlaceId || isSubmitting}
|
|
onPress={handleSave}
|
|
>
|
|
<Text className={`text-center uppercase font-bold text-lg ${selectedPlaceId ? 'text-white' : 'text-gray-500'}`}>Salva</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
|
|
{/* Loading Overlay */}
|
|
{isSubmitting && (
|
|
<View className="absolute inset-0 bg-black/60 items-center justify-center z-50">
|
|
<View className="bg-white p-8 rounded-3xl items-center shadow-2xl min-w-[200px]">
|
|
<ActivityIndicator size="large" color="#1071C2" className="mb-4" />
|
|
<Text className="text-gray-800 text-lg font-bold text-center mb-1">
|
|
{uploadProgress ? 'Caricamento foto...' : 'Salvataggio...'}
|
|
</Text>
|
|
{uploadProgress && (
|
|
<Text className="text-[#1071C2] font-black text-xl text-center mt-2">
|
|
{uploadProgress.current} / {uploadProgress.total}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|