diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 7a7efeb..e7eb138 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -5,7 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; // Importa il tuo SessionCubit e lo State import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/data/core_repository.dart'; -import 'package:flux/core/widgets/mobile_upload_screen.dart'; +import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart'; import 'package:flux/features/auth/ui/auth_screen.dart'; import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/customers/models/customer_model.dart'; @@ -110,7 +110,7 @@ class AppRouter { return BlocProvider( create: (context) => CustomerFilesBloc(customerId), - child: MobileUploadScreen( + child: CustomerMobileUploadScreen( customerId: customerId, customerName: customerName, ), diff --git a/lib/core/widgets/mobile_upload_screen.dart b/lib/core/widgets/mobile_upload_screen.dart deleted file mode 100644 index c86d80e..0000000 --- a/lib/core/widgets/mobile_upload_screen.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; - -class MobileUploadScreen extends StatelessWidget { - final String customerId; - final String customerName; - - const MobileUploadScreen({ - super.key, - required this.customerId, - required this.customerName, - }); - - @override - Widget build(BuildContext context) { - return BlocListener( - listener: (context, state) { - if (state.status == CustomerFilesStatus.success) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text("File caricato! ✅"))); - } - if (state.status == CustomerFilesStatus.failure) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text("Errore: ${state.error}"))); - } - }, - child: Scaffold( - appBar: AppBar(title: Text("Upload: $customerName")), - body: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _UploadButton( - title: "SCATTA FOTO", - icon: Icons.camera_alt_rounded, - onTap: () => _handleCamera(context), - ), - const SizedBox(height: 20), - _UploadButton( - title: "CARICA DA MEMORIA", - icon: Icons.file_present_rounded, - onTap: () => _handleFilePicker(context), - isSecondary: true, - ), - ], - ), - ), - ), - ); - } - - Future _handleCamera(BuildContext context) async { - final picker = ImagePicker(); - final photo = await picker.pickImage( - source: ImageSource.camera, - imageQuality: 80, - ); - if (photo != null && context.mounted) { - context.read().add( - UploadCustomerFileEvent(photo: File(photo.path)), - ); - } - } - - Future _handleFilePicker(BuildContext context) async { - final result = await FilePicker.pickFiles(withData: true); - if (result != null && context.mounted) { - context.read().add( - UploadCustomerFileEvent(pickedFile: result.files.first), - ); - } - } -} - -class _UploadButton extends StatelessWidget { - final String title; - final IconData icon; - final VoidCallback onTap; - final bool isSecondary; - - const _UploadButton({ - required this.title, - required this.icon, - required this.onTap, - this.isSecondary = false, - }); - - @override - Widget build(BuildContext context) { - return SizedBox( - width: double.infinity, - height: 80, - child: ElevatedButton.icon( - onPressed: onTap, - icon: Icon(icon, size: 28), - label: Text( - title, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - style: ElevatedButton.styleFrom( - backgroundColor: isSecondary ? Colors.grey[200] : null, - foregroundColor: isSecondary ? Colors.black87 : null, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ); - } -} diff --git a/lib/features/customers/blocs/customer_files_bloc.dart b/lib/features/customers/blocs/customer_files_bloc.dart index b6f8c57..0fdffe1 100644 --- a/lib/features/customers/blocs/customer_files_bloc.dart +++ b/lib/features/customers/blocs/customer_files_bloc.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; @@ -17,6 +18,7 @@ class CustomerFilesBloc extends Bloc { : super(const CustomerFilesState(status: CustomerFilesStatus.initial)) { on(_loadCustomerFiles); on(_uploadCustomerFile); + on(_uploadMultipleCustomerFiles); on(_deleteCustomerFiles); on(_toggleCustomerFileSelection); } @@ -60,6 +62,48 @@ class CustomerFilesBloc extends Bloc { } } + FutureOr _uploadMultipleCustomerFiles( + UploadMultipleCustomerFilesEvent event, + Emitter emit, + ) async { + if (event.files.isEmpty) { + emit( + state.copyWith( + status: CustomerFilesStatus.failure, + error: "Nessun file selezionato", + ), + ); + return; + } + emit(state.copyWith(status: CustomerFilesStatus.uploading, error: null)); + try { + // 2. Creiamo una lista di "Promesse" (Futures) per il repository + final List> uploadTasks = []; + for (var file in event.files) { + // Aggiungiamo il task alla lista, ma NON usiamo await qui dentro! + uploadTasks.add( + _repository.uploadAndRegisterFile( + customerId: customerId, + pickedFile: file, + ), + ); + } + // 3. ESECUZIONE PARALLELA! + // Aspettiamo che tutti i file siano caricati contemporaneamente. + await Future.wait(uploadTasks); + // 4. GRAN FINALE: Tutto caricato, emettiamo il success! + emit(state.copyWith(status: CustomerFilesStatus.success)); + } catch (e) { + // Se anche un solo file fallisce, catturiamo l'errore + emit( + state.copyWith( + status: CustomerFilesStatus.failure, + error: "Errore durante l'upload multiplo: $e", + ), + ); + } + } + Future _deleteCustomerFiles( DeleteCustomerFilesEvent event, Emitter emit, diff --git a/lib/features/customers/blocs/customer_files_events.dart b/lib/features/customers/blocs/customer_files_events.dart index b72ffee..ad235d1 100644 --- a/lib/features/customers/blocs/customer_files_events.dart +++ b/lib/features/customers/blocs/customer_files_events.dart @@ -15,6 +15,13 @@ class UploadCustomerFileEvent extends CustomerFilesEvent { const UploadCustomerFileEvent({this.pickedFile, this.photo}); } +class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent { + final List files; + const UploadMultipleCustomerFilesEvent(this.files); + @override + List get props => [files]; +} + class DeleteCustomerFilesEvent extends CustomerFilesEvent {} class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent { diff --git a/lib/features/customers/ui/customer_mobile_upload_screen.dart b/lib/features/customers/ui/customer_mobile_upload_screen.dart new file mode 100644 index 0000000..0f18079 --- /dev/null +++ b/lib/features/customers/ui/customer_mobile_upload_screen.dart @@ -0,0 +1,304 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; + +class CustomerMobileUploadScreen extends StatefulWidget { + final String customerId; + final String customerName; + + const CustomerMobileUploadScreen({ + super.key, + required this.customerId, + required this.customerName, + }); + + @override + State createState() => + _CustomerMobileUploadScreenState(); +} + +class _CustomerMobileUploadScreenState + extends State { + // 1. LA NOSTRA STAGING AREA (Il "Carrello") + final List _stagedFiles = []; + + // 2. STATO DI CARICAMENTO GLOBALE + bool _isUploading = false; + + // Funzione magica per capire se è un'immagine o un PDF dall'estensione + bool _isImage(String path) { + final ext = path.split('.').last.toLowerCase(); + return ['jpg', 'jpeg', 'png', 'webp'].contains(ext); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + // Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina! + if (state.status == CustomerFilesStatus.success && _isUploading) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Tutti i file caricati con successo! ✅"), + ), + ); + Navigator.of(context).pop(); + } + if (state.status == CustomerFilesStatus.failure) { + setState(() => _isUploading = false); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Errore: ${state.error}"))); + } + }, + child: Scaffold( + appBar: AppBar( + title: Text("Upload: ${widget.customerName}"), + // Togliamo la freccia indietro se stiamo caricando per evitare disastri + automaticallyImplyLeading: !_isUploading, + ), + body: Stack( + children: [ + Column( + children: [ + // --- SEZIONE PULSANTI (Fotocamera / Galleria) --- + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isUploading ? null : _handleCamera, + icon: const Icon(Icons.camera_alt), + label: const Text("SCATTA"), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _isUploading ? null : _handleFilePicker, + icon: const Icon(Icons.folder), + label: const Text("GALLERIA"), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ], + ), + ), + + const Divider(), + + // --- SEZIONE ANTEPRIME (La GridView Magica) --- + Expanded( + child: _stagedFiles.isEmpty + ? const Center( + child: Text( + "Nessun file selezionato.\nScatta una foto o scegli dalla galleria.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + : GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + 3, // 3 colonne come la galleria dell'iPhone + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: _stagedFiles.length, + itemBuilder: (context, index) { + final file = _stagedFiles[index]; + final isImg = _isImage(file.name); + + return Stack( + clipBehavior: Clip.none, + children: [ + // L'ANTEPRIMA + Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.grey.shade300, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: isImg + ? Image.file( + File(file.path!), + fit: BoxFit.cover, + ) + : const Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.picture_as_pdf, + color: Colors.red, + size: 36, + ), + SizedBox(height: 4), + Text( + "PDF", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + + // IL PULSANTE CESTINO (In alto a destra) + Positioned( + top: -8, + right: -8, + child: GestureDetector( + onTap: () { + setState(() { + _stagedFiles.removeAt(index); + }); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 16, + ), + ), + ), + ), + ], + ); + }, + ), + ), + + // --- SEZIONE INVIA E CHIUDI --- + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + // Il pulsante si accende SOLO se ci sono file nel carrello + onPressed: _stagedFiles.isEmpty || _isUploading + ? null + : _submitAllFiles, + icon: const Icon(Icons.cloud_upload), + label: Text( + "INVIA ${_stagedFiles.length} FILE E CHIUDI", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, + ), + ), + ), + ), + ), + ], + ), + + // --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) --- + if (_isUploading) + Container( + color: Colors.black.withValues(alpha: 0.5), + child: const Center( + child: Card( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + "Caricamento in corso...", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + } + + // --- LOGICA FOTOCAMERA E LIBRERIA --- + Future _handleCamera() async { + final picker = ImagePicker(); + final photo = await picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + if (photo != null) { + final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web! + final photoSize = await photo.length(); + + final platformFile = PlatformFile( + name: photo.name, + size: photoSize, + path: photo.path, + bytes: photoBytes, // I bytes ci salvano la vita su Supabase! + ); + setState(() { + _stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File + }); + } + } + + Future _handleFilePicker() async { + // allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo! + final result = await FilePicker.pickFiles(allowMultiple: true); + if (result != null) { + setState(() { + _stagedFiles.addAll(result.files); + }); + } + } + + // --- LOGICA DI INVIO AL BLoC --- + void _submitAllFiles() { + setState(() => _isUploading = true); + + // Diciamo al BLoC di caricare tutti i file. + // Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda) + final bloc = context.read(); + bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles)); + + // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! + } +} diff --git a/lib/features/services/blocs/service_files_bloc.dart b/lib/features/services/blocs/service_files_bloc.dart index f22214a..0298ef2 100644 --- a/lib/features/services/blocs/service_files_bloc.dart +++ b/lib/features/services/blocs/service_files_bloc.dart @@ -28,8 +28,13 @@ class ServiceFilesBloc extends Bloc { on(_onLoadServiceFiles); on(_onAddServiceFiles); on(_onUploadServiceFiles); + on(_onUploadMultipleServiceFiles); on(_onDeleteServiceFiles); on(_onToggleServiceFileSelection); + // Se il BLoC nasce con un ID, accendiamo subito lo stream! + if (serviceId != null) { + add(LoadServiceFilesEvent(serviceId: serviceId)); + } } FutureOr _onServiceSaved( @@ -80,8 +85,9 @@ class ServiceFilesBloc extends Bloc { AddServiceFilesEvent event, Emitter emit, ) async { + final currentId = state.serviceId; // BIVIO 1: PRATICA NUOVA (Nessun ID) - if (serviceId == null) { + if (currentId == null) { // Mettiamo i file nel "parcheggio" locale dello State final newLocalFiles = event.files.map((file) { return ServiceFileModel( @@ -139,7 +145,7 @@ class ServiceFilesBloc extends Bloc { if (event.pickedFiles != null && event.pickedFiles!.isNotEmpty) { for (var file in event.pickedFiles!) { await _repository.uploadAndRegisterServiceFile( - serviceId: serviceId!, + serviceId: state.serviceId!, pickedFile: file, ); } @@ -152,13 +158,75 @@ class ServiceFilesBloc extends Bloc { } } + FutureOr _onUploadMultipleServiceFiles( + UploadMultipleServiceFilesEvent event, + Emitter emit, + ) async { + if (event.files.isEmpty) { + emit( + state.copyWith( + status: ServiceFilesStatus.failure, + error: "Nessun file selezionato", + ), + ); + return; + } + emit(state.copyWith(status: ServiceFilesStatus.uploading, error: null)); + try { + // 2. Creiamo una lista di "Promesse" (Futures) per il repository + final List> uploadTasks = []; + for (var file in event.files) { + // Aggiungiamo il task alla lista, ma NON usiamo await qui dentro! + uploadTasks.add( + _repository.uploadAndRegisterServiceFile( + serviceId: state.serviceId!, + pickedFile: file, + ), + ); + } + // 3. ESECUZIONE PARALLELA! + // Aspettiamo che tutti i file siano caricati contemporaneamente. + await Future.wait(uploadTasks); + // 4. GRAN FINALE: Tutto caricato, emettiamo il success! + emit(state.copyWith(status: ServiceFilesStatus.success)); + } catch (e) { + // Se anche un solo file fallisce, catturiamo l'errore + emit( + state.copyWith( + status: ServiceFilesStatus.failure, + error: "Errore durante l'upload multiplo: $e", + ), + ); + } + } + FutureOr _onDeleteServiceFiles( DeleteServiceFilesEvent event, Emitter emit, - ) {} + ) async { + emit(state.copyWith(status: ServiceFilesStatus.loading)); + try { + await _repository.deleteServiceFiles(state.selectedFiles); + emit( + state.copyWith(status: ServiceFilesStatus.success, selectedFiles: []), + ); + } catch (e) { + emit( + state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()), + ); + } + } FutureOr _onToggleServiceFileSelection( ToggleServiceFileSelectionEvent event, Emitter emit, - ) {} + ) { + List selectedFiles = List.from(state.selectedFiles); + if (selectedFiles.contains(event.file)) { + selectedFiles.remove(event.file); + } else { + selectedFiles.add(event.file); + } + emit(state.copyWith(selectedFiles: selectedFiles)); + } } diff --git a/lib/features/services/blocs/service_files_events.dart b/lib/features/services/blocs/service_files_events.dart index d1a0de7..141d5ac 100644 --- a/lib/features/services/blocs/service_files_events.dart +++ b/lib/features/services/blocs/service_files_events.dart @@ -41,6 +41,13 @@ class UploadServiceFilesEvent extends ServiceFilesEvent { List get props => [pickedFiles, photos]; } +class UploadMultipleServiceFilesEvent extends ServiceFilesEvent { + final List files; + const UploadMultipleServiceFilesEvent(this.files); + @override + List get props => [files]; +} + class DeleteServiceFilesEvent extends ServiceFilesEvent {} class ToggleServiceFileSelectionEvent extends ServiceFilesEvent { diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index 0330e28..8cb47db 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -203,19 +203,31 @@ class ServicesCubit extends Cubit { // --- PERSISTENZA --- - Future saveCurrentService({required bool isBozza}) async { + Future saveCurrentService({ + required bool isBozza, + bool shouldPop = true, + List? files, + }) async { if (state.currentService == null) return; emit(state.copyWith(status: ServicesStatus.saving, errorMessage: null)); try { // 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente - final serviceToSave = state.currentService!.copyWith(isBozza: isBozza); + final serviceToSave = state.currentService!.copyWith( + isBozza: isBozza, + files: files, + ); // 2. Salvataggio corazzato - await _repository.saveFullService(serviceToSave); + final updatedService = await _repository.saveFullService(serviceToSave); // 3. Reset e ricaricamento - emit(state.copyWith(status: ServicesStatus.saved, currentService: null)); + emit( + state.copyWith( + status: shouldPop ? ServicesStatus.saved : ServicesStatus.savedNoPop, + currentService: shouldPop ? null : updatedService, + ), + ); await loadServices(refresh: true); } catch (e) { emit( diff --git a/lib/features/services/blocs/services_state.dart b/lib/features/services/blocs/services_state.dart index 00439fd..9d5a15a 100644 --- a/lib/features/services/blocs/services_state.dart +++ b/lib/features/services/blocs/services_state.dart @@ -1,6 +1,15 @@ part of 'services_cubit.dart'; -enum ServicesStatus { initial, loading, ready, saving, saved, success, failure } +enum ServicesStatus { + initial, + loading, + ready, + saving, + saved, + savedNoPop, + success, + failure, +} class ServicesState extends Equatable { final ServicesStatus status; @@ -10,6 +19,7 @@ class ServicesState extends Equatable { final String query; final DateTimeRange? dateRange; final bool hasReachedMax; + final bool isSavingDraft; const ServicesState({ required this.status, @@ -19,6 +29,7 @@ class ServicesState extends Equatable { this.query = '', this.dateRange, this.hasReachedMax = false, + this.isSavingDraft = false, }); ServicesState copyWith({ @@ -29,6 +40,7 @@ class ServicesState extends Equatable { String? query, DateTimeRange? dateRange, bool? hasReachedMax, + bool? isSavingDraft, }) { return ServicesState( status: status ?? this.status, @@ -38,6 +50,7 @@ class ServicesState extends Equatable { query: query ?? this.query, dateRange: dateRange ?? this.dateRange, hasReachedMax: hasReachedMax ?? this.hasReachedMax, + isSavingDraft: isSavingDraft ?? this.isSavingDraft, ); } @@ -50,5 +63,6 @@ class ServicesState extends Equatable { query, dateRange, hasReachedMax, + isSavingDraft, ]; } diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index e99d7aa..abfb672 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -335,4 +335,25 @@ class ServicesRepository { ); await _customerRepository.saveFileReference(fileToCopy); } + + Future deleteServiceFiles(List files) async { + if (files.isEmpty) return; + // 1. Prepariamo le liste di ID e di Percorsi + final List idsToDelete = files.map((f) => f.id!).toList(); + final List storagePaths = files.map((f) => f.storagePath).toList(); + + try { + await _supabase.from('service_file').delete().inFilter('id', idsToDelete); + + await _supabase.storage.from('documents').remove(storagePaths); + + debugPrint("Eliminati con successo ${files.length} file."); + } on PostgrestException catch (e) { + debugPrint("Errore DB: ${e.message}"); + throw 'Errore database: ${e.message}'; + } catch (e) { + debugPrint("Errore generico: $e"); + throw 'Errore durante l\'eliminazione dei file: $e'; + } + } } diff --git a/lib/features/services/ui/service_form_screen/attachment_section.dart b/lib/features/services/ui/service_form_screen/attachment_section.dart index 622db8f..cb81224 100644 --- a/lib/features/services/ui/service_form_screen/attachment_section.dart +++ b/lib/features/services/ui/service_form_screen/attachment_section.dart @@ -5,7 +5,6 @@ import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart'; -import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/services/blocs/service_files_bloc.dart'; import 'package:flux/features/services/blocs/services_cubit.dart'; import 'package:flux/features/services/models/service_file_model.dart'; @@ -23,7 +22,7 @@ class AttachmentsSection extends StatelessWidget { ); if (result != null && context.mounted) { - context.read().addAttachments(result.files); + context.read().add(AddServiceFilesEvent(result.files)); } } @@ -277,7 +276,11 @@ class AttachmentsSection extends StatelessWidget { if (confirm != true) return; // Utente ha annullato // Salviamo forzatamente in bozza - await cubit.saveCurrentService(isBozza: true); + await cubit.saveCurrentService( + isBozza: true, + shouldPop: false, + files: serviceFilesBloc.state.localFiles, + ); // Recuperiamo il servizio aggiornato con l'ID! currentService = cubit.state.currentService; diff --git a/lib/features/services/ui/service_form_screen/service_form_screen.dart b/lib/features/services/ui/service_form_screen/service_form_screen.dart index 3068c00..6574c5b 100644 --- a/lib/features/services/ui/service_form_screen/service_form_screen.dart +++ b/lib/features/services/ui/service_form_screen/service_form_screen.dart @@ -51,7 +51,8 @@ class _ServiceFormScreenState extends State { ), ); Navigator.pop(context); - } else if (state.status == ServicesStatus.failure) { + } + if (state.status == ServicesStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text("Errore: ${state.errorMessage ?? ''}"), @@ -59,6 +60,14 @@ class _ServiceFormScreenState extends State { ), ); } + if (state.status == ServicesStatus.savedNoPop) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Pratica salvata con successo!"), + backgroundColor: Colors.green, + ), + ); + } }, builder: (context, state) { final service = state.currentService; diff --git a/lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart b/lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart index 6fbb01c..08e306a 100644 --- a/lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart +++ b/lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart @@ -21,122 +21,282 @@ class ServiceMobileUploadScreen extends StatefulWidget { } class _ServiceMobileUploadScreenState extends State { - final List _pickedFiles = []; - final List _photos = []; + // 1. LA NOSTRA STAGING AREA (Il "Carrello") + final List _stagedFiles = []; + + // 2. STATO DI CARICAMENTO GLOBALE + bool _isUploading = false; + + // Funzione magica per capire se è un'immagine o un PDF dall'estensione + bool _isImage(String path) { + final ext = path.split('.').last.toLowerCase(); + return ['jpg', 'jpeg', 'png', 'webp'].contains(ext); + } @override Widget build(BuildContext context) { return BlocListener( listener: (context, state) { - if (state.status == ServiceFilesStatus.success) { - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text("File caricato! ✅"))); + // Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina! + if (state.status == ServiceFilesStatus.success && _isUploading) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Tutti i file caricati con successo! ✅"), + ), + ); + Navigator.of(context).pop(); } if (state.status == ServiceFilesStatus.failure) { + setState(() => _isUploading = false); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text("Errore: ${state.error}"))); } }, child: Scaffold( - appBar: AppBar(title: Text("Upload Pratica:\n${widget.serviceName}")), - body: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - width: double.infinity, - height: 80, - child: ElevatedButton.icon( - onPressed: () => _handleCamera(context), - icon: const Icon(Icons.camera_alt_rounded, size: 28), - label: const Text( - "SCATTA FOTO", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + appBar: AppBar( + title: Text("Upload Pratica:\n${widget.serviceName}"), + automaticallyImplyLeading: !_isUploading, + ), + body: Stack( + children: [ + Column( + children: [ + // --- SEZIONE PULSANTI (Fotocamera / Galleria) --- + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _isUploading ? null : _handleCamera, + icon: const Icon(Icons.camera_alt), + label: const Text("SCATTA"), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: _isUploading ? null : _handleFilePicker, + icon: const Icon(Icons.folder), + label: const Text("GALLERIA"), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ), + ], ), ), - ), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - height: 80, - child: ElevatedButton.icon( - onPressed: () => _handleFilePicker(context), - icon: const Icon(Icons.file_present_rounded, size: 28), - label: const Text( - "CARICA DA MEMORIA", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[200], - foregroundColor: Colors.black87, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - ), - ), - ), - const SizedBox(height: 30), - SizedBox( - width: double.infinity, - height: 80, - child: ElevatedButton.icon( - onPressed: () => _handleSaveAndClose(context), - icon: const Icon(Icons.save_alt_rounded, size: 28), - label: const Text( - "INVIA E CHIUDI", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + const Divider(), + + // --- SEZIONE ANTEPRIME (La GridView Magica) --- + Expanded( + child: _stagedFiles.isEmpty + ? const Center( + child: Text( + "Nessun file selezionato.\nScatta una foto o scegli dalla galleria.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ) + : GridView.builder( + padding: const EdgeInsets.all(16), + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: + 3, // 3 colonne come la galleria dell'iPhone + crossAxisSpacing: 12, + mainAxisSpacing: 12, + ), + itemCount: _stagedFiles.length, + itemBuilder: (context, index) { + final file = _stagedFiles[index]; + final isImg = _isImage(file.name); + + return Stack( + clipBehavior: Clip.none, + children: [ + // L'ANTEPRIMA + Container( + width: double.infinity, + height: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.grey.shade300, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: isImg + ? Image.file( + File(file.path!), + fit: BoxFit.cover, + ) + : const Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Icon( + Icons.picture_as_pdf, + color: Colors.red, + size: 36, + ), + SizedBox(height: 4), + Text( + "PDF", + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + + // IL PULSANTE CESTINO (In alto a destra) + Positioned( + top: -8, + right: -8, + child: GestureDetector( + onTap: () { + setState(() { + _stagedFiles.removeAt(index); + }); + }, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 16, + ), + ), + ), + ), + ], + ); + }, + ), + ), + + // --- SEZIONE INVIA E CHIUDI --- + SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + // Il pulsante si accende SOLO se ci sono file nel carrello + onPressed: _stagedFiles.isEmpty || _isUploading + ? null + : _submitAllFiles, + icon: const Icon(Icons.cloud_upload), + label: Text( + "INVIA ${_stagedFiles.length} FILE E CHIUDI", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimary, + ), + ), + ), ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.grey[200], - foregroundColor: Colors.black87, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + ), + ], + ), + + // --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) --- + if (_isUploading) + Container( + color: Colors.black.withValues(alpha: 0.5), + child: const Center( + child: Card( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text( + "Caricamento in corso...", + style: TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), ), ), ), ), - ], - ), + ], ), ), ); } - Future _handleCamera(BuildContext context) async { + // --- LOGICA FOTOCAMERA E LIBRERIA --- + Future _handleCamera() async { final picker = ImagePicker(); final photo = await picker.pickImage( source: ImageSource.camera, imageQuality: 80, ); - if (photo != null && context.mounted) { + if (photo != null) { + final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web! + final photoSize = await photo.length(); + + final platformFile = PlatformFile( + name: photo.name, + size: photoSize, + path: photo.path, + bytes: photoBytes, // I bytes ci salvano la vita su Supabase! + ); setState(() { - _photos.add(File(photo.path)); + _stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File }); } } - Future _handleFilePicker(BuildContext context) async { - final result = await FilePicker.pickFiles(withData: true); - if (result != null && context.mounted) { + Future _handleFilePicker() async { + // allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo! + final result = await FilePicker.pickFiles(allowMultiple: true); + if (result != null) { setState(() { - _pickedFiles.addAll(result.files); + _stagedFiles.addAll(result.files); }); } } - Future _handleSaveAndClose(BuildContext context) async { - context.read().add( - UploadServiceFilesEvent(pickedFiles: _pickedFiles, photos: _photos), - ); - Navigator.pop(context); + // --- LOGICA DI INVIO AL BLoC --- + void _submitAllFiles() { + setState(() => _isUploading = true); + + // Diciamo al BLoC di caricare tutti i file. + // Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda) + final bloc = context.read(); + bloc.add(UploadMultipleServiceFilesEvent(_stagedFiles)); + + // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! } }