From a06807cd1fbfd0bb8807af236affdec7699edc1b Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Fri, 24 Apr 2026 12:39:22 +0200 Subject: [PATCH] forse Co-authored-by: Copilot --- lib/core/routes/app_router.dart | 35 +++ .../customers/blocs/customer_files_bloc.dart | 4 +- .../blocs/customer_files_events.dart | 2 +- .../customers/data/customer_repository.dart | 5 - .../services/blocs/service_files_bloc.dart | 125 ++++++++++ .../services/blocs/service_files_events.dart | 49 ++++ .../services/blocs/service_files_state.dart | 52 ++++ .../services/blocs/services_cubit.dart | 64 +++-- .../services/data/services_repository.dart | 103 ++++++-- .../services/models/service_file_model.dart | 2 + .../attachment_section.dart | 224 +++++++++++++++--- .../service_mobile_upload_screen.dart | 105 ++++++++ pubspec.lock | 12 +- 13 files changed, 703 insertions(+), 79 deletions(-) create mode 100644 lib/features/services/blocs/service_files_bloc.dart create mode 100644 lib/features/services/blocs/service_files_events.dart create mode 100644 lib/features/services/blocs/service_files_state.dart create mode 100644 lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 256edb4..dc06c60 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -5,6 +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/auth/ui/auth_screen.dart'; import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/customers/models/customer_model.dart'; @@ -13,8 +14,10 @@ import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/products/ui/products_screen.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; +import 'package:flux/features/services/blocs/service_files_bloc.dart'; import 'package:flux/features/services/models/service_model.dart'; import 'package:flux/features/services/ui/service_form_screen/service_form_screen.dart'; +import 'package:flux/features/services/ui/service_form_screen/service_mobile_upload_screen.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -97,7 +100,23 @@ class AppRouter { ); }, ), + GoRoute( + path: '/customer/:id/upload', + builder: (context, state) { + final customerId = state.pathParameters['id']!; + // Recuperiamo il nome dalle query se vogliamo mostrarlo nel titolo, + // oppure lo caricherà il bloc. + final customerName = state.uri.queryParameters['name'] ?? 'Cliente'; + return BlocProvider( + create: (context) => CustomerFilesBloc(customerId), + child: MobileUploadScreen( + customerId: customerId, + customerName: customerName, + ), + ); + }, + ), GoRoute( path: '/products', name: 'products', @@ -118,6 +137,22 @@ class AppRouter { ); }, ), + GoRoute( + path: '/service/:id/upload', + builder: (context, state) { + final serviceId = state.pathParameters['id']!; + final serviceName = state.uri.queryParameters['name'] ?? 'Pratica'; + + return BlocProvider( + // Inizializziamo il bloc col serviceId corretto! + create: (context) => ServiceFilesBloc(serviceId: serviceId), + child: ServiceMobileUploadScreen( + serviceId: serviceId, + serviceName: serviceName, + ), + ); + }, + ), ], ); } diff --git a/lib/features/customers/blocs/customer_files_bloc.dart b/lib/features/customers/blocs/customer_files_bloc.dart index ae03aaa..b6f8c57 100644 --- a/lib/features/customers/blocs/customer_files_bloc.dart +++ b/lib/features/customers/blocs/customer_files_bloc.dart @@ -17,7 +17,7 @@ class CustomerFilesBloc extends Bloc { : super(const CustomerFilesState(status: CustomerFilesStatus.initial)) { on(_loadCustomerFiles); on(_uploadCustomerFile); - on(_deleteCustomerFiles); + on(_deleteCustomerFiles); on(_toggleCustomerFileSelection); } void _loadCustomerFiles( @@ -61,7 +61,7 @@ class CustomerFilesBloc extends Bloc { } Future _deleteCustomerFiles( - DeleteCustomerFileEvent event, + DeleteCustomerFilesEvent event, Emitter emit, ) async { emit(state.copyWith(status: CustomerFilesStatus.loading)); diff --git a/lib/features/customers/blocs/customer_files_events.dart b/lib/features/customers/blocs/customer_files_events.dart index 8bdf725..b72ffee 100644 --- a/lib/features/customers/blocs/customer_files_events.dart +++ b/lib/features/customers/blocs/customer_files_events.dart @@ -15,7 +15,7 @@ class UploadCustomerFileEvent extends CustomerFilesEvent { const UploadCustomerFileEvent({this.pickedFile, this.photo}); } -class DeleteCustomerFileEvent extends CustomerFilesEvent {} +class DeleteCustomerFilesEvent extends CustomerFilesEvent {} class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent { final CustomerFileModel file; diff --git a/lib/features/customers/data/customer_repository.dart b/lib/features/customers/data/customer_repository.dart index d4a3b68..6cf5203 100644 --- a/lib/features/customers/data/customer_repository.dart +++ b/lib/features/customers/data/customer_repository.dart @@ -107,11 +107,6 @@ class CustomerRepository { } } - /// Salva il riferimento del file nel DB - Future saveCustomerFile(CustomerFileModel file) async { - await _supabase.from('customer_file').insert(file.toMap()); - } - /// Carica un file e salva il riferimento nel database Future uploadAndRegisterFile({ required String customerId, diff --git a/lib/features/services/blocs/service_files_bloc.dart b/lib/features/services/blocs/service_files_bloc.dart new file mode 100644 index 0000000..d6f069e --- /dev/null +++ b/lib/features/services/blocs/service_files_bloc.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flux/core/utils/string_extensions.dart'; +import 'package:flux/features/services/data/services_repository.dart'; +import 'package:flux/features/services/models/service_file_model.dart'; +import 'package:flux/features/services/models/service_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'service_files_events.dart'; +part 'service_files_state.dart'; + +class ServiceFilesBloc extends Bloc { + final _repository = GetIt.I.get(); + final String? serviceId; + + ServiceFilesBloc({this.serviceId}) + : super( + ServiceFilesState( + status: ServiceFilesStatus.initial, + serviceId: serviceId, + ), + ) { + on(_onServiceSaved); + on(_onLoadServiceFiles); + on(_onAddServiceFiles); + on(_onUploadServiceFiles); + on(_onDeleteServiceFiles); + on(_onToggleServiceFileSelection); + } + + FutureOr _onServiceSaved( + ServiceSavedEvent event, + Emitter emit, + ) { + emit(state.copyWith(serviceId: event.serviceId)); + } + + FutureOr _onLoadServiceFiles( + LoadServiceFilesEvent event, + Emitter emit, + ) async { + if (serviceId != null) { + emit(state.copyWith(status: ServiceFilesStatus.loading)); + await emit.forEach( + _repository.getServiceFilesStream(serviceId!), + onData: (data) => state.copyWith( + status: ServiceFilesStatus.success, + remoteFiles: data, + ), + onError: (error, stackTrace) => state.copyWith( + status: ServiceFilesStatus.failure, + error: error.toString(), + ), + ); + } + } + + void _onAddServiceFiles( + AddServiceFilesEvent event, + Emitter emit, + ) async { + // BIVIO 1: PRATICA NUOVA (Nessun ID) + if (serviceId == null) { + // Mettiamo i file nel "parcheggio" locale dello State + final newLocalFiles = event.files.map((file) { + return ServiceFileModel( + id: null, + serviceId: serviceId ?? '', + name: file.name.fileNameWithoutExtension(), + extension: file.name.fileExtension(), + storagePath: '', + fileSize: file.size, + localBytes: file.bytes, + ); + }).toList(); + final List updatedLocalFiles = [ + ...state.localFiles, + ...newLocalFiles, + ]; + emit( + state.copyWith( + localFiles: updatedLocalFiles, + status: ServiceFilesStatus.success, + ), + ); + return; + } + + // BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID) + emit(state.copyWith(status: ServiceFilesStatus.uploading)); + try { + // Logica identica a quella che abbiamo fatto per i clienti + for (var file in event.files) { + await _repository.uploadAndRegisterServiceFile( + serviceId: serviceId!, + pickedFile: file, + ); + } + emit(state.copyWith(status: ServiceFilesStatus.success)); + } catch (e) { + emit( + state.copyWith(status: ServiceFilesStatus.failure, error: e.toString()), + ); + } + } + + FutureOr _onUploadServiceFiles( + UploadServiceFilesEvent event, + Emitter emit, + ) {} + + FutureOr _onDeleteServiceFiles( + DeleteServiceFilesEvent event, + Emitter emit, + ) {} + + FutureOr _onToggleServiceFileSelection( + ToggleServiceFileSelectionEvent event, + Emitter emit, + ) {} +} diff --git a/lib/features/services/blocs/service_files_events.dart b/lib/features/services/blocs/service_files_events.dart new file mode 100644 index 0000000..cb794ef --- /dev/null +++ b/lib/features/services/blocs/service_files_events.dart @@ -0,0 +1,49 @@ +part of 'service_files_bloc.dart'; + +abstract class ServiceFilesEvent extends Equatable { + const ServiceFilesEvent(); + + @override + List get props => []; +} + +class ServiceSavedEvent extends ServiceFilesEvent { + final String serviceId; + const ServiceSavedEvent(this.serviceId); + + @override + List get props => [serviceId]; +} + +class LoadServiceFilesEvent extends ServiceFilesEvent { + final String? serviceId; + final ServiceModel? service; + const LoadServiceFilesEvent({this.serviceId, this.service}); + + @override + List get props => [serviceId, service]; +} + +class AddServiceFilesEvent extends ServiceFilesEvent { + final List files; + const AddServiceFilesEvent(this.files); + + @override + List get props => [files]; +} + +class UploadServiceFilesEvent extends ServiceFilesEvent { + final PlatformFile? pickedFile; + final File? photo; + const UploadServiceFilesEvent({this.pickedFile, this.photo}); + + @override + List get props => [pickedFile, photo]; +} + +class DeleteServiceFilesEvent extends ServiceFilesEvent {} + +class ToggleServiceFileSelectionEvent extends ServiceFilesEvent { + final ServiceFileModel file; + const ToggleServiceFileSelectionEvent(this.file); +} diff --git a/lib/features/services/blocs/service_files_state.dart b/lib/features/services/blocs/service_files_state.dart new file mode 100644 index 0000000..f39a133 --- /dev/null +++ b/lib/features/services/blocs/service_files_state.dart @@ -0,0 +1,52 @@ +part of 'service_files_bloc.dart'; + +enum ServiceFilesStatus { initial, loading, uploading, success, failure } + +class ServiceFilesState extends Equatable { + const ServiceFilesState({ + this.serviceId, + required this.status, + this.error, + this.localFiles = const [], + this.remoteFiles = const [], + this.selectedFiles = const [], + }); + + final String? serviceId; + final ServiceFilesStatus status; + final String? error; + final List localFiles; + final List remoteFiles; + + final List selectedFiles; + + @override + List get props => [ + serviceId, + status, + error, + localFiles, + remoteFiles, + selectedFiles, + ]; + + List get allFiles => [...remoteFiles, ...localFiles]; + + ServiceFilesState copyWith({ + String? serviceId, + ServiceFilesStatus? status, + String? error, + List? localFiles, + List? remoteFiles, + List? selectedFiles, + }) { + return ServiceFilesState( + serviceId: serviceId ?? this.serviceId, + status: status ?? this.status, + error: error, + localFiles: localFiles ?? this.localFiles, + remoteFiles: remoteFiles ?? this.remoteFiles, + selectedFiles: selectedFiles ?? this.selectedFiles, + ); + } +} diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index 010f5aa..0330e28 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -12,6 +12,7 @@ import 'package:flux/features/services/models/service_file_model.dart'; import 'package:flux/features/services/models/service_model.dart'; import 'package:get_it/get_it.dart'; import 'package:collection/collection.dart'; +import 'package:permission_handler/permission_handler.dart'; part 'services_state.dart'; class ServicesCubit extends Cubit { @@ -273,49 +274,62 @@ class ServicesCubit extends Cubit { ); } - void saveAndCopyFileToCustomer(ServiceFileModel file) async { + void saveAndCopyFileToCustomer(List selectedFiles) async { final currentService = state.currentService; + + // 1. Check di sicurezza: se non c'è il cliente, non sappiamo dove copiare if (currentService == null || currentService.customerId == null) { - // Magari mostra un errore: non posso copiare al cliente se non c'è un cliente! + emit( + state.copyWith( + status: ServicesStatus.failure, + errorMessage: + "Impossibile copiare: nessun cliente associato alla pratica.", + ), + ); return; } emit(state.copyWith(status: ServicesStatus.loading)); try { - // 1. Salviamo la pratica (Bozza o definitiva che sia) - // Questo assicura che il file sia stato caricato su Storage e censito su DB - await saveCurrentService(isBozza: currentService.isBozza); + // 2. SALVATAGGIO CORAZZATO + // Chiamiamo il repo e otteniamo la pratica con TUTTI i file ora dotati di ID e storagePath + final updatedService = await _repository.saveFullService(currentService); - // 2. Recuperiamo il file "aggiornato" - // Dopo il saveCurrentService, il file che prima era "locale" ora ha un URL. - // Lo cerchiamo nella lista aggiornata per nome o estensione. - final savedFile = state.currentService!.files.firstWhere( - (f) => f.name == file.name && f.extension == file.extension, - orElse: () => file, - ); + // 3. COPIA RELAZIONALE + // Per ogni file che l'utente ha selezionato nella UI, cerchiamo la sua versione + // "ufficiale" (quella con lo storagePath) nel modello appena tornato dal DB. + for (var selectedFile in selectedFiles) { + // Cerchiamo il match nel modello aggiornato + final persistedFile = updatedService.files.firstWhere( + (f) => + f.name == selectedFile.name && + f.extension == selectedFile.extension, + orElse: () => throw Exception( + "File ${selectedFile.name} non trovato dopo il salvataggio.", + ), + ); - if (savedFile.storagePath.isEmpty) { - throw Exception( - "Errore: URL del file non trovato dopo il salvataggio.", + // Creiamo il link nel database del cliente + await _repository.copyFileToCustomer( + file: persistedFile, + customerId: currentService.customerId!, ); } - // 3. Chiamiamo il repository per la copia fisica nel database del cliente - // Passiamo l'URL del file e l'ID del cliente - await _repository.copyFileToCustomer( - file: savedFile, - customerId: currentService.customerId!, + // 4. AGGIORNAMENTO STATO + // Aggiorniamo il Cubit con il servizio salvato così la UI mostra i file come "Remoti" + emit( + state.copyWith( + status: ServicesStatus.success, + currentService: updatedService, + ), ); - - // 4. Feedback all'utente - // Potresti emettere un successo o mostrare un toast - emit(state.copyWith(status: ServicesStatus.success)); } catch (e) { emit( state.copyWith( status: ServicesStatus.failure, - errorMessage: "Errore durante la copia del file: $e", + errorMessage: "Errore durante il salvataggio e copia: $e", ), ); } diff --git a/lib/features/services/data/services_repository.dart b/lib/features/services/data/services_repository.dart index a1f6c68..e99d7aa 100644 --- a/lib/features/services/data/services_repository.dart +++ b/lib/features/services/data/services_repository.dart @@ -1,5 +1,7 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/utils/string_extensions.dart'; import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/services/models/service_file_model.dart'; @@ -83,7 +85,7 @@ class ServicesRepository { } // --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) --- - Future saveFullService(ServiceModel service) async { + Future saveFullService(ServiceModel service) async { try { // 1. Upsert del record principale final serviceData = await _supabase @@ -150,15 +152,23 @@ class ServicesRepository { if (insertTasks.isNotEmpty) { await Future.wait(insertTasks); } - if (service.files.isNotEmpty) { + + // 4. UPLOAD DEI FILE LOCALI (Nuovi) + // Filtriamo solo i file che non hanno ancora un ID (quindi sono locali) + final localFilesToUpload = service.files + .where((f) => f.id == null) + .toList(); + + if (localFilesToUpload.isNotEmpty) { final List uploadTasks = []; - for (var file in service.files) { + for (var file in localFilesToUpload) { final storagePath = '$companyId/services/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}'; final String mimeType = file.extension.toLowerCase() == 'pdf' ? 'application/pdf' : 'image/${file.extension}'; + final fileToSave = file.copyWith( serviceId: newId, storagePath: storagePath, @@ -166,22 +176,16 @@ class ServicesRepository { // Creiamo una funzione asincrona per caricare file e scrivere nel DB Future uploadAndLink() async { - // Determiniamo il MIME type corretto in base all'estensione - // A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!) await _supabase.storage .from('documents') .uploadBinary( storagePath, fileToSave.localBytes!, - fileOptions: FileOptions( - contentType: - mimeType, // Diciamo a Supabase esattamente cos'è! - upsert: - true, // Opzionale: sovrascrive se esiste già un file con lo stesso nome - ), + fileOptions: FileOptions(contentType: mimeType, upsert: true), ); + // B. Inserimento riga nel DB relazionale await _supabase.from('service_file').insert(fileToSave.toMap()); } @@ -191,6 +195,23 @@ class ServicesRepository { // Eseguiamo tutti gli upload in parallelo per la massima velocità await Future.wait(uploadTasks); } + + // 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO + // Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati + // (inclusi quelli della tabella service_file appena inseriti) + final updatedServiceData = await _supabase + .from('service') + .select(''' + *, + energy_service(*), + fin_service(*), + entertainment_service(*), + service_file(*) + ''') + .eq('id', newId) + .single(); + + return ServiceModel.fromMap(updatedServiceData); } catch (e) { // Qui potresti aggiungere una logica di "rollback manuale" se necessario throw Exception('Errore durante il salvataggio corazzato: $e'); @@ -239,12 +260,66 @@ class ServicesRepository { } /// Ascolta in tempo reale i file caricati per una pratica - Stream>> getServiceFilesStream(String serviceId) { + Stream> getServiceFilesStream(String serviceId) { return _supabase .from('service_file') .stream(primaryKey: ['id']) .eq('service_id', serviceId) - .order('created_at', ascending: false); + .order('created_at', ascending: false) + .map( + (listOfMaps) => + listOfMaps.map((map) => ServiceFileModel.fromMap(map)).toList(), + ); + } + + Future uploadAndRegisterServiceFile({ + required String serviceId, + required PlatformFile pickedFile, + }) async { + final cleanFileName = pickedFile.name.replaceAll( + RegExp(r'[^a-zA-Z0-9\.\-]'), + '_', + ); + final storagePath = + '$companyId/services/$serviceId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName'; + final int fileSize = pickedFile.size; + final fileToSave = ServiceFileModel( + serviceId: serviceId, + name: cleanFileName.fileNameWithoutExtension(), + extension: cleanFileName.fileExtension(), + storagePath: storagePath, + fileSize: fileSize, + ); + final String mimeType = fileToSave.extension.toLowerCase() == 'pdf' + ? 'application/pdf' + : 'image/${fileToSave.extension}'; + try { + // Usiamo bytes invece del path per massima compatibilità + if (pickedFile.bytes == null && pickedFile.path == null) { + throw 'Impossibile leggere il contenuto del file'; + } + + // Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes + if (pickedFile.bytes != null) { + await _supabase.storage + .from('documents') + .uploadBinary( + storagePath, + pickedFile.bytes!, + fileOptions: FileOptions(contentType: mimeType, upsert: true), + ); + } + + final response = await _supabase + .from('service_file') + .insert(fileToSave.toMap()) + .select() + .single(); + + return ServiceFileModel.fromMap(response); + } catch (e) { + throw 'Errore durante l\'upload: $e'; + } } Future copyFileToCustomer({ @@ -258,6 +333,6 @@ class ServicesRepository { extension: file.extension, fileSize: file.fileSize, ); - await _customerRepository.saveCustomerFile(fileToCopy); + await _customerRepository.saveFileReference(fileToCopy); } } diff --git a/lib/features/services/models/service_file_model.dart b/lib/features/services/models/service_file_model.dart index 3549ba3..f804166 100644 --- a/lib/features/services/models/service_file_model.dart +++ b/lib/features/services/models/service_file_model.dart @@ -23,6 +23,8 @@ class ServiceFileModel extends Equatable { this.localBytes, }); + bool get isLocal => localBytes != null; + // Trasforma i byte in qualcosa di leggibile (KB, MB, GB) String get sizeFormatted { if (fileSize <= 0) return "0 B"; 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 70f0bef..cdf9e31 100644 --- a/lib/features/services/ui/service_form_screen/attachment_section.dart +++ b/lib/features/services/ui/service_form_screen/attachment_section.dart @@ -1,8 +1,11 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +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/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'; @@ -25,13 +28,16 @@ class AttachmentsSection extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final files = state.currentService?.files ?? []; + ServiceFilesBloc serviceFilesBloc = BlocProvider.of( + context, + ); + return BlocBuilder( + builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // --- HEADER SEZIONE --- Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -43,16 +49,38 @@ class AttachmentsSection extends StatelessWidget { letterSpacing: 1.2, ), ), - OutlinedButton.icon( - icon: const Icon(Icons.attach_file), - label: const Text("Aggiungi File"), - onPressed: () => _pickFiles(context), + Row( + children: [ + OutlinedButton.icon( + icon: const Icon(Icons.attach_file), + label: const Text("Aggiungi File"), + onPressed: () => _pickFiles(context), + ), + if (!context.read().state.isMobileDevice) ...[ + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: () => _handleGenerateQr(context), + icon: const Icon(Icons.qr_code), + label: const Text("GENERA QR"), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + foregroundColor: Theme.of( + context, + ).colorScheme.primary, + elevation: 0, + ), + ), + ], + ], ), ], ), const SizedBox(height: 12), - if (files.isEmpty) + // --- LISTA VUOTA --- + if (state.allFiles.isEmpty) // Furbata: usiamo allFiles.isEmpty! Container( width: double.infinity, padding: const EdgeInsets.all(24), @@ -70,34 +98,49 @@ class AttachmentsSection extends StatelessWidget { style: TextStyle(color: Colors.grey), ), ) - else + // --- LISTA PIENA --- + else ...[ ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - itemCount: files.length, + itemCount: state.allFiles.length, itemBuilder: (context, index) { - final file = files[index]; - // Calcoliamo la dimensione in MB + final file = state.allFiles[index]; final sizeMb = (file.fileSize / (1024 * 1024)) .toStringAsFixed(2); - - // Scegliamo un'icona in base al tipo di file final isPdf = file.extension.toLowerCase() == 'pdf'; + final isSelected = state.selectedFiles.contains(file); return GestureDetector( - onTap: () => _handleSingleClick(context, file), + onTap: () => serviceFilesBloc.add( + ToggleServiceFileSelectionEvent(file), + ), onDoubleTap: () => _handleDoubleClick(context, file), child: Card( margin: const EdgeInsets.only(bottom: 8), elevation: 0, + // UX Fina: cambiamo colore del bordo se selezionato shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), - side: BorderSide(color: Colors.grey.shade300), + side: BorderSide( + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade300, + width: isSelected ? 2 : 1, + ), ), + // UX Fina: Sfondo leggermente colorato se selezionato + color: isSelected + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.05) + : Theme.of(context).colorScheme.surface, child: ListTile( leading: Icon( - isPdf ? Icons.picture_as_pdf : Icons.image, - color: isPdf ? Colors.red : Colors.blue, + isSelected + ? Icons.check_box + : Icons.check_box_outline_blank, + color: Theme.of(context).colorScheme.primary, size: 32, ), title: Text( @@ -105,35 +148,156 @@ class AttachmentsSection extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, ), - subtitle: Text("$sizeMb MB"), - trailing: IconButton( - icon: const Icon( - Icons.delete_outline, - color: Colors.red, - ), - onPressed: () => context - .read() - .removeAttachment(index), + subtitle: Text( + file.isLocal ? "$sizeMb MB • (Nuovo)" : "$sizeMb MB", + ), + trailing: Icon( + isPdf ? Icons.picture_as_pdf : Icons.image, + color: isPdf ? Colors.red : Colors.blue, + size: 32, ), ), ), ); }, ), + + // --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) --- + // Appare SOLO se c'è almeno un file selezionato + if (state.selectedFiles.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + // Contatore + Text( + "${state.selectedFiles.length} file selezionati", + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const Spacer(), + + // Bottone Elimina + TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: Colors.red, + ), + icon: const Icon(Icons.delete_outline), + label: const Text("Elimina"), + onPressed: () { + // Qui lancerai l'evento per eliminare i file selezionati! + // Es: serviceFilesBloc.add(DeleteSelectedFilesEvent()); + }, + ), + const SizedBox(width: 8), + + // Bottone Copia + ElevatedButton.icon( + icon: const Icon(Icons.copy), + label: const Text("Copia in Cliente"), + onPressed: () => saveAndCopyFilesToCustomer( + context, + state.selectedFiles, + ), + ), + ], + ), + ), + ), + ], ], ); }, ); } + Future _handleGenerateQr(BuildContext context) async { + final cubit = context.read(); + var currentService = cubit.state.currentService; + + // 1. SE LA PRATICA E' NUOVA (Manca l'ID) + if (currentService == null || currentService.id == null) { + // Chiediamo conferma + final bool? confirm = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text("Salvataggio Necessario"), + content: const Text( + "Per generare il QR Code e caricare file dal telefono, la pratica deve essere prima salvata in BOZZA.\n\nVuoi salvare ora?", + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text("Annulla"), + ), + ElevatedButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text("Salva in Bozza"), + ), + ], + ), + ); + + if (confirm != true) return; // Utente ha annullato + + // Salviamo forzatamente in bozza + await cubit.saveCurrentService(isBozza: true); + + // Recuperiamo il servizio aggiornato con l'ID! + currentService = cubit.state.currentService; + + if (currentService?.id == null) { + // Se c'è stato un errore nel salvataggio, usciamo + return; + } + } + + // 2. ORA ABBIAMO L'ID SICURO -> MOSTRIAMO IL QR! + if (context.mounted) { + // Creiamo un nome leggibile da passare nel link + final nomePratica = "Pratica ${currentService?.customerDisplayName ?? ''}" + .trim(); + + showDialog( + context: context, + builder: (context) => QrUploadDialog( + deepLinkUrl: + 'fluxapp://service/${currentService!.id}/upload?name=${Uri.encodeComponent(nomePratica)}', + title: 'Scatta per\n$nomePratica', + ), + ); + } + } + // --- LOGICA DI COPIA AL CLIENTE --- - void _handleSingleClick(BuildContext context, ServiceFileModel file) { + void saveAndCopyFilesToCustomer( + BuildContext context, + List files, + ) { showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text("Copia nei documenti Cliente"), content: const Text( - "Vuoi copiare questo file nell'anagrafica del cliente? \n\n" + "Vuoi copiare i file selezionati nell'anagrafica del cliente? \n\n" "Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.", ), actions: [ @@ -145,7 +309,7 @@ class AttachmentsSection extends StatelessWidget { onPressed: () { Navigator.pop(ctx); // 1. Diciamo al Cubit di salvare in Bozza e fare la copia - context.read().saveAndCopyFileToCustomer(file); + context.read().saveAndCopyFileToCustomer(files); }, child: const Text("Salva e Copia"), ), 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 new file mode 100644 index 0000000..a093579 --- /dev/null +++ b/lib/features/services/ui/service_form_screen/service_mobile_upload_screen.dart @@ -0,0 +1,105 @@ +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/services/blocs/service_files_bloc.dart'; + +class ServiceMobileUploadScreen extends StatelessWidget { + final String serviceId; + final String serviceName; + + const ServiceMobileUploadScreen({ + super.key, + required this.serviceId, + required this.serviceName, + }); + + @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! ✅"))); + } + if (state.status == ServiceFilesStatus.failure) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Errore: ${state.error}"))); + } + }, + child: Scaffold( + appBar: AppBar(title: Text("Upload Pratica:\n$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), + ), + ), + ), + ), + 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), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + 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( + UploadServiceFilesEvent(photo: File(photo.path)), + ); + } + } + + Future _handleFilePicker(BuildContext context) async { + final result = await FilePicker.pickFiles(withData: true); + if (result != null && context.mounted) { + context.read().add( + UploadServiceFilesEvent(pickedFile: result.files.first), + ); + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 80ec3e1..b1c4635 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -340,10 +340,10 @@ packages: dependency: transitive description: name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" http: dependency: transitive description: @@ -776,6 +776,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.3" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" retry: dependency: transitive description: