From ec06155f2b2f3f2d5eb26912d7c87870f05cd8aa Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 10:17:48 +0200 Subject: [PATCH] fantascenza attachment bloc agnostico, ora continuo refactor rimuovendo customer file bloc ecc. --- lib/core/routes/app_router.dart | 12 +- .../shared_forms/operation_files_section.dart | 45 +- .../attachments/blocs/attachments_bloc.dart | 391 ++++++++++++++++++ .../attachments/blocs/attachments_events.dart | 68 +++ ...iles_state.dart => attachments_state.dart} | 51 ++- .../blocs/operation_files_bloc.dart | 389 ----------------- .../blocs/operation_files_events.dart | 81 ---- .../data/attachments_repository.dart | 187 ++++++++- .../attachments/models/attachment_model.dart | 7 + .../ui/operation_mobile_upload_screen.dart | 12 +- 10 files changed, 715 insertions(+), 528 deletions(-) create mode 100644 lib/features/attachments/blocs/attachments_bloc.dart create mode 100644 lib/features/attachments/blocs/attachments_events.dart rename lib/features/attachments/blocs/{operation_files_state.dart => attachments_state.dart} (53%) delete mode 100644 lib/features/attachments/blocs/operation_files_bloc.dart delete mode 100644 lib/features/attachments/blocs/operation_files_events.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 4ad187b..0160e15 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -24,7 +24,7 @@ import 'package:flux/features/master_data/staff/ui/staff_screen.dart'; import 'package:flux/features/master_data/store/ui/stores_screen.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; -import 'package:flux/features/attachments/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart'; @@ -203,8 +203,9 @@ class AppRouter { context.read().loadStaffForStore(currentStoreId); return BlocProvider( - create: (context) => OperationFilesBloc( - operationId: operationId ?? existingOperation?.id, + create: (context) => AttachmentsBloc( + parentId: operationId ?? existingOperation?.id, + parentType: AttachmentParentType.operation, ), child: OperationFormScreen( operationId: operationId ?? existingOperation?.id, @@ -232,7 +233,10 @@ class AppRouter { context.read().loadBrands(); context.read().loadStaffForStore(currentStoreId); return BlocProvider( - create: (context) => OperationFilesBloc(operationId: operationId), + create: (context) => AttachmentsBloc( + parentId: operationId, + parentType: AttachmentParentType.operation, + ), child: OperationMobileUploadScreen( operationId: operationId, operationName: operationName, diff --git a/lib/core/widgets/shared_forms/operation_files_section.dart b/lib/core/widgets/shared_forms/operation_files_section.dart index 5a0dc36..56f5620 100644 --- a/lib/core/widgets/shared_forms/operation_files_section.dart +++ b/lib/core/widgets/shared_forms/operation_files_section.dart @@ -7,7 +7,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart'; import 'package:flux/features/attachments/ui/quick_rename_dialog.dart'; -import 'package:flux/features/attachments/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flux/features/operations/models/operation_model.dart'; @@ -88,16 +88,14 @@ class _OperationFilesSectionState extends State { if (result != null && mounted) { // MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC! - context.read().add( - AddOperationFilesEvent(result.files), - ); + context.read().add(AddAttachmentsEvent(result.files)); } } // --- APERTURA VIEWER --- void _openFile(AttachmentModel file) { // 1. Catturiamo il BLoC dalla pagina corrente prima di navigare - final operationFilesBloc = context.read(); + final operationFilesBloc = context.read(); Navigator.push( context, MaterialPageRoute( @@ -107,10 +105,10 @@ class _OperationFilesSectionState extends State { attachment: file, onRename: (newName) { // Spara l'evento al BLoC e lui farà il resto! - operationFilesBloc.add(RenameOperationFileEvent(file, newName)); + operationFilesBloc.add(RenameAttachmentEvent(file, newName)); }, onDelete: () { - operationFilesBloc.add(DeleteSpecificOperationFileEvent(file)); + operationFilesBloc.add(DeleteSpecificAttachmentEvent(file)); }, ), ), @@ -392,7 +390,7 @@ class _OperationFilesSectionState extends State { final theme = Theme.of(context); // USIAMO IL TUO BLOC! - return BlocBuilder( + return BlocBuilder( builder: (context, state) { final allFiles = state.allFiles; final selectedFiles = state.selectedFiles; @@ -442,7 +440,7 @@ class _OperationFilesSectionState extends State { ElevatedButton.icon( icon: const Icon(Icons.add_photo_alternate), label: const Text('Aggiungi File'), - onPressed: state.status == OperationFilesStatus.uploading + onPressed: state.status == AttachmentsStatus.uploading ? null : _pickFiles, ), @@ -463,12 +461,12 @@ class _OperationFilesSectionState extends State { ), onPressed: () { if (selectedFiles.length == allFiles.length) { - context.read().add( - ClearOperationFileSelectionEvent(), + context.read().add( + ClearAttachmentSelectionEvent(), ); } else { - context.read().add( - SelectAllOperationFilesEvent(), + context.read().add( + SelectAllAttachmentsEvent(), ); } }, @@ -477,7 +475,7 @@ class _OperationFilesSectionState extends State { const SizedBox(width: 12), // Loader di upload - if (state.status == OperationFilesStatus.uploading) + if (state.status == AttachmentsStatus.uploading) const SizedBox( width: 24, height: 24, @@ -493,8 +491,8 @@ class _OperationFilesSectionState extends State { icon: const Icon(Icons.delete, color: Colors.red), tooltip: 'Elimina selezionati', onPressed: () { - context.read().add( - DeleteOperationFilesEvent(), + context.read().add( + DeleteAttachmentsEvent(), ); }, ), @@ -505,9 +503,10 @@ class _OperationFilesSectionState extends State { icon: const Icon(Icons.person_add, color: Colors.blue), tooltip: 'Copia nei documenti del Cliente', onPressed: () { - context.read().add( - LinkFilesToCustomerEvent( - customerId: widget.currentOp.customerId!, + context.read().add( + LinkAttachmentsToEntityEvent( + targetId: widget.currentOp.customerId!, + targetType: AttachmentParentType.customer, ), ); ScaffoldMessenger.of(context).showSnackBar( @@ -621,8 +620,8 @@ class _OperationFilesSectionState extends State { onTap: () => _openFile(file), onLongPress: () { // Selezione rapida con long press! - context.read().add( - ToggleOperationFileSelectionEvent(file), + context.read().add( + ToggleAttachmentSelectionEvent(file), ); }, borderRadius: BorderRadius.circular(8), @@ -696,8 +695,8 @@ class _OperationFilesSectionState extends State { right: 4, child: InkWell( onTap: () { - context.read().add( - ToggleOperationFileSelectionEvent(file), + context.read().add( + ToggleAttachmentSelectionEvent(file), ); }, child: Container( diff --git a/lib/features/attachments/blocs/attachments_bloc.dart b/lib/features/attachments/blocs/attachments_bloc.dart new file mode 100644 index 0000000..3c2b665 --- /dev/null +++ b/lib/features/attachments/blocs/attachments_bloc.dart @@ -0,0 +1,391 @@ +import 'dart:async'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/utils/extensions.dart'; +import 'package:flux/features/attachments/data/attachments_repository.dart'; +import 'package:flux/features/attachments/models/attachment_model.dart'; +import 'package:get_it/get_it.dart'; +import 'package:image_picker/image_picker.dart'; + +part 'attachments_events.dart'; +part 'attachments_state.dart'; + +class AttachmentsBloc extends Bloc { + final _repository = GetIt.I.get(); + + AttachmentsBloc({String? parentId, required AttachmentParentType parentType}) + : super( + AttachmentsState( + status: AttachmentsStatus.initial, + parentId: parentId, + parentType: parentType, + ), + ) { + on(_onParentEntitySaved); + on(_onLoadAttachments); + on(_onAddAttachments); + on(_onUploadAttachments); + on(_onDeleteAttachments); + on(_onToggleAttachmentSelection); + on(_onLinkAttachmentsToEntity); + on(_onRenameAttachment); + on(_onDeleteSpecificAttachment); + on(_onSelectAllAttachments); + on(_onClearAttachmentSelection); + + // Se il BLoC nasce già con un ID, carichiamo i file + if (parentId != null) { + add(LoadAttachmentsEvent(parentId: parentId)); + } + } + FutureOr _onParentEntitySaved( + ParentEntitySavedEvent event, + Emitter emit, + ) async { + emit( + state.copyWith( + parentId: event.newParentId, + status: AttachmentsStatus.uploading, + ), + ); + + if (state.localFiles.isNotEmpty) { + try { + final List> uploadTasks = state.localFiles.map((file) { + final fakePlatformFile = PlatformFile( + name: '${file.name}.${file.extension}', + size: file.fileSize, + bytes: file.localBytes, + ); + + // Chiamiamo il metodo generico passando il parentId e il TYPE + return _repository.uploadAndRegisterFile( + parentId: event.newParentId, + parentType: state.parentType, + pickedFile: fakePlatformFile, + ); + }).toList(); + + await Future.wait(uploadTasks); + } catch (e) { + emit( + state.copyWith( + status: AttachmentsStatus.failure, + error: "Errore upload post-salvataggio: $e", + ), + ); + return; + } + } + + emit(state.copyWith(localFiles: [], status: AttachmentsStatus.success)); + add(LoadAttachmentsEvent(parentId: event.newParentId)); + } + + FutureOr _onLoadAttachments( + LoadAttachmentsEvent event, + Emitter emit, + ) async { + final currentId = event.parentId ?? state.parentId; + + if (currentId != null) { + emit(state.copyWith(status: AttachmentsStatus.loading)); + + await emit.forEach( + _repository.getFilesStream( + currentId, + state.parentType, + ), // Passiamo il tipo! + onData: (List data) => state.copyWith( + status: AttachmentsStatus.success, + remoteFiles: data, + ), + onError: (error, stackTrace) => state.copyWith( + status: AttachmentsStatus.failure, + error: error.toString(), + ), + ); + } + } + + void _onAddAttachments( + AddAttachmentsEvent event, + Emitter emit, + ) async { + final currentId = state.parentId; + + // BIVIO 1: PRATICA NUOVA (Salvataggio locale) + if (currentId == null) { + final companyId = GetIt.I.get().state.company!.id!; + final newLocalFiles = event.files.map((file) { + // Assegniamo i campi dinamicamente in base al parentType! + return AttachmentModel( + id: null, + companyId: companyId, + operationId: state.parentType == AttachmentParentType.operation + ? '' + : null, + ticketId: state.parentType == AttachmentParentType.ticket ? '' : null, + customerId: state.parentType == AttachmentParentType.customer + ? '' + : null, + name: file.name.fileNameWithoutExtension(), + extension: file.name.fileExtension(), + storagePath: '', + fileSize: file.size, + localBytes: file.bytes, + ); + }).toList(); + + emit( + state.copyWith( + localFiles: [...state.localFiles, ...newLocalFiles], + status: AttachmentsStatus.success, + ), + ); + return; + } + + // BIVIO 2: PRATICA ESISTENTE (Upload immediato) + emit(state.copyWith(status: AttachmentsStatus.uploading)); + try { + final List> uploadTasks = event.files.map((file) { + return _repository.uploadAndRegisterFile( + parentId: currentId, + parentType: state.parentType, + pickedFile: file, + ); + }).toList(); + + await Future.wait(uploadTasks); + emit(state.copyWith(status: AttachmentsStatus.success)); + } catch (e) { + emit( + state.copyWith(status: AttachmentsStatus.failure, error: e.toString()), + ); + } + } + + FutureOr _onUploadAttachments( + UploadAttachmentsEvent event, + Emitter emit, + ) async { + if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) && + (event.photos == null || event.photos!.isEmpty)) { + return; + } + + if (state.parentId == null) return; + + emit(state.copyWith(status: AttachmentsStatus.uploading)); + try { + final List> uploadTasks = []; + + // 1. Gestione Documenti normali (PlatformFile) + if (event.pickedFiles != null) { + for (var file in event.pickedFiles!) { + uploadTasks.add( + _repository.uploadAndRegisterFile( + parentId: state.parentId!, + parentType: state.parentType, + pickedFile: file, + ), + ); + } + } + + // 2. Gestione Foto Fotocamera (XFile) + if (event.photos != null) { + for (var photo in event.photos!) { + // Leggiamo i byte asincronamente + final bytes = await photo.readAsBytes(); + final fileSize = await photo.length(); + + // Lo travestiamo da PlatformFile per passarlo al Repository! + final fakePlatformFile = PlatformFile( + name: photo.name, + size: fileSize, + bytes: bytes, + path: photo.path, + ); + + uploadTasks.add( + _repository.uploadAndRegisterFile( + parentId: state.parentId!, + parentType: state.parentType, + pickedFile: fakePlatformFile, + ), + ); + } + } + + // Esecuzione parallela di tutti i documenti e foto + await Future.wait(uploadTasks); + emit(state.copyWith(status: AttachmentsStatus.success)); + } catch (e) { + emit( + state.copyWith(status: AttachmentsStatus.failure, error: e.toString()), + ); + } + } + + FutureOr _onDeleteAttachments( + DeleteAttachmentsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(status: AttachmentsStatus.loading)); + try { + await _repository.deleteFiles( + files: state.selectedFiles, + currentContextType: state.parentType, + ); + emit( + state.copyWith(status: AttachmentsStatus.success, selectedFiles: []), + ); + } catch (e) { + emit( + state.copyWith(status: AttachmentsStatus.failure, error: e.toString()), + ); + } + } + + FutureOr _onToggleAttachmentSelection( + ToggleAttachmentSelectionEvent event, + Emitter emit, + ) { + final selectedFiles = List.from(state.selectedFiles); + if (selectedFiles.contains(event.file)) { + selectedFiles.remove(event.file); + } else { + selectedFiles.add(event.file); + } + emit(state.copyWith(selectedFiles: selectedFiles)); + } + + void _onSelectAllAttachments( + SelectAllAttachmentsEvent event, + Emitter emit, + ) { + // Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati + emit(state.copyWith(selectedFiles: state.allFiles)); + } + + void _onClearAttachmentSelection( + ClearAttachmentSelectionEvent event, + Emitter emit, + ) { + // Svuotiamo brutalmente la lista + emit(state.copyWith(selectedFiles: [])); + } + + FutureOr _onLinkAttachmentsToEntity( + LinkAttachmentsToEntityEvent event, + Emitter emit, + ) async { + if (state.selectedFiles.isEmpty) return; + + // BIVIO 1: PRATICA/TICKET NON ANCORA SALVATA (Modalità Locale) + if (state.parentId == null) { + final updatedLocalFiles = state.localFiles.map((file) { + if (state.selectedFiles.contains(file)) { + // Assegniamo dinamicamente l'ID in base all'entità scelta + switch (event.targetType) { + case AttachmentParentType.customer: + return file.copyWith(customerId: event.targetId); + case AttachmentParentType.ticket: + return file.copyWith(ticketId: event.targetId); + case AttachmentParentType.operation: + return file.copyWith(operationId: event.targetId); + } + } + return file; + }).toList(); + + emit( + state.copyWith( + localFiles: updatedLocalFiles, + selectedFiles: [], // Svuotiamo la selezione + status: AttachmentsStatus.success, + ), + ); + return; + } + + // BIVIO 2: PRATICA/TICKET ESISTENTE (Modalità Remota su DB) + emit(state.copyWith(status: AttachmentsStatus.loading)); + try { + final List> linkTasks = []; + + for (var file in state.selectedFiles) { + if (file.id != null) { + linkTasks.add( + _repository.linkFileToEntity( + fileId: file.id!, + targetType: event.targetType, + targetId: event.targetId, + ), + ); + } + } + + await Future.wait(linkTasks); + + // Lo stream aggiornerà automaticamente la UI + emit( + state.copyWith(status: AttachmentsStatus.success, selectedFiles: []), + ); + } catch (e) { + emit( + state.copyWith( + status: AttachmentsStatus.failure, + error: "Errore durante il collegamento: $e", + ), + ); + } + } + + FutureOr _onRenameAttachment( + RenameAttachmentEvent event, + Emitter emit, + ) async { + // BIVIO 1: File Locale (Bozza) + if (event.file.localBytes != null) { + final updatedLocalFiles = state.localFiles.map((f) { + if (f == event.file) { + return f.copyWith(name: event.newName); + } + return f; + }).toList(); + emit(state.copyWith(localFiles: updatedLocalFiles)); + return; + } + + // BIVIO 2: File Remoto (Salvato su DB) + emit(state.copyWith(status: AttachmentsStatus.loading)); + try { + await _repository.renameAttachment(event.file.id!, event.newName); + emit(state.copyWith(status: AttachmentsStatus.success)); + } catch (e) { + emit( + state.copyWith( + status: AttachmentsStatus.failure, + error: "Errore rinomina: $e", + ), + ); + } + } + + FutureOr _onDeleteSpecificAttachment( + DeleteSpecificAttachmentEvent event, + Emitter emit, + ) { + if (event.file.localBytes != null) { + final updatedLocalFiles = state.localFiles + .where((f) => f != event.file) + .toList(); + emit(state.copyWith(localFiles: updatedLocalFiles)); + } + } +} diff --git a/lib/features/attachments/blocs/attachments_events.dart b/lib/features/attachments/blocs/attachments_events.dart new file mode 100644 index 0000000..f968e63 --- /dev/null +++ b/lib/features/attachments/blocs/attachments_events.dart @@ -0,0 +1,68 @@ +part of 'attachments_bloc.dart'; + +abstract class AttachmentsEvent extends Equatable { + const AttachmentsEvent(); + + @override + List get props => []; +} + +/// Chiamato quando l'entità "padre" (es. il Ticket) viene salvata per la prima volta +class ParentEntitySavedEvent extends AttachmentsEvent { + final String newParentId; + const ParentEntitySavedEvent(this.newParentId); + + @override + List get props => [newParentId]; +} + +class LoadAttachmentsEvent extends AttachmentsEvent { + final String? parentId; + const LoadAttachmentsEvent({this.parentId}); +} + +class AddAttachmentsEvent extends AttachmentsEvent { + final List files; + const AddAttachmentsEvent(this.files); +} + +class UploadAttachmentsEvent extends AttachmentsEvent { + final List? pickedFiles; + final List? photos; + const UploadAttachmentsEvent({this.pickedFiles, this.photos}); +} + +class DeleteAttachmentsEvent extends AttachmentsEvent {} + +class ToggleAttachmentSelectionEvent extends AttachmentsEvent { + final AttachmentModel file; + const ToggleAttachmentSelectionEvent(this.file); +} + +class SelectAllAttachmentsEvent extends AttachmentsEvent {} + +class ClearAttachmentSelectionEvent extends AttachmentsEvent {} + +class LinkAttachmentsToEntityEvent extends AttachmentsEvent { + final AttachmentParentType targetType; + final String targetId; + + const LinkAttachmentsToEntityEvent({ + required this.targetType, + required this.targetId, + }); + + @override + List get props => [targetType, targetId]; +} + +class RenameAttachmentEvent extends AttachmentsEvent { + final AttachmentModel file; + final String newName; + const RenameAttachmentEvent(this.file, this.newName); +} + +class DeleteSpecificAttachmentEvent extends AttachmentsEvent { + final AttachmentModel file; + const DeleteSpecificAttachmentEvent(this.file); +} diff --git a/lib/features/attachments/blocs/operation_files_state.dart b/lib/features/attachments/blocs/attachments_state.dart similarity index 53% rename from lib/features/attachments/blocs/operation_files_state.dart rename to lib/features/attachments/blocs/attachments_state.dart index a102dd4..94de53c 100644 --- a/lib/features/attachments/blocs/operation_files_state.dart +++ b/lib/features/attachments/blocs/attachments_state.dart @@ -1,10 +1,28 @@ -part of 'operation_files_bloc.dart'; +part of 'attachments_bloc.dart'; -enum OperationFilesStatus { initial, loading, uploading, success, failure } +enum AttachmentsStatus { initial, loading, uploading, success, failure } -class OperationFilesState extends Equatable { - const OperationFilesState({ - this.operationId, +enum AttachmentParentType { + operation('operation_id'), + ticket('ticket_id'), + customer('customer_id'); + + final String dbColumn; + const AttachmentParentType(this.dbColumn); +} + +class AttachmentsState extends Equatable { + final String? parentId; + final AttachmentParentType parentType; + final AttachmentsStatus status; + final String? error; + final List localFiles; + final List remoteFiles; + final List selectedFiles; + + const AttachmentsState({ + this.parentId, + required this.parentType, required this.status, this.error, this.localFiles = const [], @@ -12,17 +30,10 @@ class OperationFilesState extends Equatable { this.selectedFiles = const [], }); - final String? operationId; - final OperationFilesStatus status; - final String? error; - final List localFiles; - final List remoteFiles; - - final List selectedFiles; - @override List get props => [ - operationId, + parentId, + parentType, status, error, localFiles, @@ -32,16 +43,18 @@ class OperationFilesState extends Equatable { List get allFiles => [...remoteFiles, ...localFiles]; - OperationFilesState copyWith({ - String? operationId, - OperationFilesStatus? status, + AttachmentsState copyWith({ + String? parentId, + AttachmentParentType? parentType, + AttachmentsStatus? status, String? error, List? localFiles, List? remoteFiles, List? selectedFiles, }) { - return OperationFilesState( - operationId: operationId ?? this.operationId, + return AttachmentsState( + parentId: parentId ?? this.parentId, + parentType: parentType ?? this.parentType, status: status ?? this.status, error: error, localFiles: localFiles ?? this.localFiles, diff --git a/lib/features/attachments/blocs/operation_files_bloc.dart b/lib/features/attachments/blocs/operation_files_bloc.dart deleted file mode 100644 index 7a48387..0000000 --- a/lib/features/attachments/blocs/operation_files_bloc.dart +++ /dev/null @@ -1,389 +0,0 @@ -import 'dart:async'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flux/core/blocs/session/session_cubit.dart'; -import 'package:flux/core/utils/extensions.dart'; -import 'package:flux/features/attachments/models/attachment_model.dart'; -import 'package:flux/features/operations/data/operations_repository.dart'; -import 'package:get_it/get_it.dart'; -import 'package:image_picker/image_picker.dart'; - -part 'operation_files_events.dart'; -part 'operation_files_state.dart'; - -class OperationFilesBloc - extends Bloc { - final _repository = GetIt.I.get(); - final String? operationId; - - OperationFilesBloc({this.operationId}) - : super( - OperationFilesState( - status: OperationFilesStatus.initial, - operationId: operationId, - ), - ) { - on(_onOperationsaved); - on(_onLoadOperationFiles); - on(_onAddOperationFiles); - on(_onUploadOperationFiles); - on(_onDeleteOperationFiles); - on(_onToggleOperationFileSelection); - on(_onLinkFilesToCustomer); - on(_onRenameOperationFile); - on(_onDeleteSpecificOperationFiles); - on(_onSelectAllOperationFiles); - on(_onClearOperationFileSelection); - - // Se il BLoC nasce con un ID, accendiamo subito lo stream! - if (operationId != null) { - add(LoadOperationFilesEvent(operationId: operationId)); - } - } - - FutureOr _onOperationsaved( - OperationsavedEvent event, - Emitter emit, - ) async { - // 1. Aggiorniamo l'ID e mettiamo in loading - emit( - state.copyWith( - operationId: event.operationId, - status: OperationFilesStatus.uploading, - ), - ); - - // 2. RECUPERO E UPLOAD DEI FILE "PARCHEGGIATI" (Pratica Nuova) - if (state.localFiles.isNotEmpty) { - try { - final List> uploadTasks = []; - - for (var file in state.localFiles) { - // Ricreiamo il PlatformFile dal nostro AttachmentModel - // così il repository lo accetta senza fare storie! - final fakePlatformFile = PlatformFile( - name: '${file.name}.${file.extension}', - size: file.fileSize, - bytes: file.localBytes, - ); - - uploadTasks.add( - _repository.uploadAndRegisterOperationFile( - operationId: event.operationId, // L'ID APPENA NATO! - pickedFile: fakePlatformFile, - ), - ); - } - - // Lanciamo tutti gli upload in parallelo - await Future.wait(uploadTasks); - } catch (e) { - emit( - state.copyWith( - status: OperationFilesStatus.failure, - error: "Errore upload post-salvataggio: $e", - ), - ); - return; // Ci fermiamo qui se esplode qualcosa - } - } - - // 3. FINE DEI GIOCHI! Svuotiamo i locali, passiamo a success e accendiamo lo Stream - emit(state.copyWith(localFiles: [], status: OperationFilesStatus.success)); - - add(LoadOperationFilesEvent(operationId: event.operationId)); - } - - FutureOr _onLoadOperationFiles( - LoadOperationFilesEvent event, - Emitter emit, - ) async { - final currentId = event.operationId ?? state.operationId; - - if (currentId != null) { - emit(state.copyWith(status: OperationFilesStatus.loading)); - - await emit.forEach( - _repository.getOperationFilesStream(currentId), - onData: (List data) => state.copyWith( - status: OperationFilesStatus.success, - remoteFiles: data, - ), - onError: (error, stackTrace) => state.copyWith( - status: OperationFilesStatus.failure, - error: error.toString(), - ), - ); - } - } - - void _onAddOperationFiles( - AddOperationFilesEvent event, - Emitter emit, - ) async { - final currentId = state.operationId; - - // BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale) - if (currentId == null) { - final companyId = GetIt.I.get().state.company!.id!; - final newLocalFiles = event.files.map((file) { - return AttachmentModel( - id: null, - companyId: companyId, - operationId: '', // Sarà riempito al salvataggio - name: file.name.fileNameWithoutExtension(), - extension: file.name.fileExtension(), - storagePath: '', - fileSize: file.size, - localBytes: file.bytes, - ); - }).toList(); - - emit( - state.copyWith( - localFiles: [...state.localFiles, ...newLocalFiles], - status: OperationFilesStatus.success, - ), - ); - return; - } - - // BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID - Upload immediato) - emit(state.copyWith(status: OperationFilesStatus.uploading)); - try { - final List> uploadTasks = []; - for (var file in event.files) { - uploadTasks.add( - _repository.uploadAndRegisterOperationFile( - operationId: currentId, - pickedFile: file, - ), - ); - } - await Future.wait(uploadTasks); - emit(state.copyWith(status: OperationFilesStatus.success)); - } catch (e) { - emit( - state.copyWith( - status: OperationFilesStatus.failure, - error: e.toString(), - ), - ); - } - } - - FutureOr _onUploadOperationFiles( - UploadOperationFilesEvent event, - Emitter emit, - ) async { - if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) && - (event.photos == null || event.photos!.isEmpty)) { - return; - } - - if (state.operationId == null) return; - - emit(state.copyWith(status: OperationFilesStatus.uploading)); - try { - final List> uploadTasks = []; - - // 1. Gestione Documenti normali (PlatformFile) - if (event.pickedFiles != null) { - for (var file in event.pickedFiles!) { - uploadTasks.add( - _repository.uploadAndRegisterOperationFile( - operationId: state.operationId!, - pickedFile: file, - ), - ); - } - } - - // 2. Gestione Foto Fotocamera (XFile) - if (event.photos != null) { - for (var photo in event.photos!) { - // Leggiamo i byte asincronamente - final bytes = await photo.readAsBytes(); - final fileSize = await photo.length(); - - // Lo travestiamo da PlatformFile per passarlo al Repository! - final fakePlatformFile = PlatformFile( - name: photo.name, - size: fileSize, - bytes: bytes, - path: photo.path, - ); - - uploadTasks.add( - _repository.uploadAndRegisterOperationFile( - operationId: state.operationId!, - pickedFile: fakePlatformFile, - ), - ); - } - } - - // Esecuzione parallela di tutti i documenti e foto - await Future.wait(uploadTasks); - emit(state.copyWith(status: OperationFilesStatus.success)); - } catch (e) { - emit( - state.copyWith( - status: OperationFilesStatus.failure, - error: e.toString(), - ), - ); - } - } - - FutureOr _onDeleteOperationFiles( - DeleteOperationFilesEvent event, - Emitter emit, - ) async { - emit(state.copyWith(status: OperationFilesStatus.loading)); - try { - await _repository.deleteOperationFiles(state.selectedFiles); - emit( - state.copyWith(status: OperationFilesStatus.success, selectedFiles: []), - ); - } catch (e) { - emit( - state.copyWith( - status: OperationFilesStatus.failure, - error: e.toString(), - ), - ); - } - } - - FutureOr _onToggleOperationFileSelection( - ToggleOperationFileSelectionEvent event, - Emitter emit, - ) { - final selectedFiles = List.from(state.selectedFiles); - if (selectedFiles.contains(event.file)) { - selectedFiles.remove(event.file); - } else { - selectedFiles.add(event.file); - } - emit(state.copyWith(selectedFiles: selectedFiles)); - } - - void _onSelectAllOperationFiles( - SelectAllOperationFilesEvent event, - Emitter emit, - ) { - // Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati - emit(state.copyWith(selectedFiles: state.allFiles)); - } - - void _onClearOperationFileSelection( - ClearOperationFileSelectionEvent event, - Emitter emit, - ) { - // Svuotiamo brutalmente la lista - emit(state.copyWith(selectedFiles: [])); - } - - FutureOr _onLinkFilesToCustomer( - LinkFilesToCustomerEvent event, - Emitter emit, - ) async { - if (state.selectedFiles.isEmpty) return; - - // BIVIO 1: PRATICA NUOVA (Modalità Locale) - if (state.operationId == null) { - // Mappiamo i file locali: se sono tra quelli selezionati, iniettiamo il customerId - final updatedLocalFiles = state.localFiles.map((file) { - if (state.selectedFiles.contains(file)) { - return file.copyWith(customerId: event.customerId); - } - return file; - }).toList(); - - emit( - state.copyWith( - localFiles: updatedLocalFiles, - selectedFiles: [], // Svuotiamo la selezione dopo averli associati - status: OperationFilesStatus.success, // o un toast di feedback - ), - ); - return; - } - - // BIVIO 2: PRATICA ESISTENTE (Modalità Remota su DB) - emit(state.copyWith(status: OperationFilesStatus.loading)); - try { - final List> linkTasks = []; - - for (var file in state.selectedFiles) { - linkTasks.add( - _repository.copyFileToCustomer( - file: file, - customerId: event.customerId, - ), - ); - } - - await Future.wait(linkTasks); - - // Svuotiamo la selezione. - // NON serve aggiornare la lista a mano, perché il DB si aggiorna - // e lo Stream di Supabase spingerà automaticamente in UI i file aggiornati! - emit( - state.copyWith(status: OperationFilesStatus.success, selectedFiles: []), - ); - } catch (e) { - emit( - state.copyWith( - status: OperationFilesStatus.failure, - error: "Errore associazione: $e", - ), - ); - } - } - - FutureOr _onRenameOperationFile( - RenameOperationFileEvent event, - Emitter emit, - ) async { - // BIVIO 1: File Locale (Bozza) - if (event.file.localBytes != null) { - final updatedLocalFiles = state.localFiles.map((f) { - if (f == event.file) { - return f.copyWith(name: event.newName); - } - return f; - }).toList(); - emit(state.copyWith(localFiles: updatedLocalFiles)); - return; - } - - // BIVIO 2: File Remoto (Salvato su DB) - emit(state.copyWith(status: OperationFilesStatus.loading)); - try { - await _repository.renameAttachment(event.file.id!, event.newName); - emit(state.copyWith(status: OperationFilesStatus.success)); - } catch (e) { - emit( - state.copyWith( - status: OperationFilesStatus.failure, - error: "Errore rinomina: $e", - ), - ); - } - } - - FutureOr _onDeleteSpecificOperationFiles( - DeleteSpecificOperationFileEvent event, - Emitter emit, - ) { - if (event.file.localBytes != null) { - final updatedLocalFiles = state.localFiles - .where((f) => f != event.file) - .toList(); - emit(state.copyWith(localFiles: updatedLocalFiles)); - } - } -} diff --git a/lib/features/attachments/blocs/operation_files_events.dart b/lib/features/attachments/blocs/operation_files_events.dart deleted file mode 100644 index f80dfce..0000000 --- a/lib/features/attachments/blocs/operation_files_events.dart +++ /dev/null @@ -1,81 +0,0 @@ -part of 'operation_files_bloc.dart'; - -abstract class OperationFilesEvent extends Equatable { - const OperationFilesEvent(); - - @override - List get props => []; -} - -class OperationsavedEvent extends OperationFilesEvent { - final String operationId; - const OperationsavedEvent(this.operationId); - - @override - List get props => [operationId]; -} - -class LoadOperationFilesEvent extends OperationFilesEvent { - final String? operationId; - final AttachmentModel? operation; - const LoadOperationFilesEvent({this.operationId, this.operation}); - - @override - List get props => [operationId, operation]; -} - -class AddOperationFilesEvent extends OperationFilesEvent { - final List files; - const AddOperationFilesEvent(this.files); - - @override - List get props => [files]; -} - -class UploadOperationFilesEvent extends OperationFilesEvent { - final List? pickedFiles; - final List? photos; - const UploadOperationFilesEvent({this.pickedFiles, this.photos}); - - @override - List get props => [pickedFiles, photos]; -} - -class LinkFilesToCustomerEvent extends OperationFilesEvent { - final String customerId; - - const LinkFilesToCustomerEvent({required this.customerId}); - - @override - List get props => [customerId]; -} - -class DeleteOperationFilesEvent extends OperationFilesEvent {} - -class ToggleOperationFileSelectionEvent extends OperationFilesEvent { - final AttachmentModel file; - const ToggleOperationFileSelectionEvent(this.file); -} - -class RenameOperationFileEvent extends OperationFilesEvent { - final AttachmentModel file; - final String newName; - - const RenameOperationFileEvent(this.file, this.newName); - - @override - List get props => [file, newName]; -} - -class DeleteSpecificOperationFileEvent extends OperationFilesEvent { - final AttachmentModel file; - - const DeleteSpecificOperationFileEvent(this.file); - - @override - List get props => [file]; -} - -class SelectAllOperationFilesEvent extends OperationFilesEvent {} - -class ClearOperationFileSelectionEvent extends OperationFilesEvent {} diff --git a/lib/features/attachments/data/attachments_repository.dart b/lib/features/attachments/data/attachments_repository.dart index a7760b3..573987d 100644 --- a/lib/features/attachments/data/attachments_repository.dart +++ b/lib/features/attachments/data/attachments_repository.dart @@ -1,23 +1,198 @@ import 'dart:typed_data'; - +import 'package:file_picker/file_picker.dart'; +import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; class AttachmentsRepository { final _supabase = Supabase.instance.client; + static const String _bucketName = 'attachments'; + static const String _tableName = + 'attachment'; // Cambia col vero nome della tua tabella se diverso! /// Scarica i byte di un file direttamente da Supabase Storage Future downloadAttachmentBytes(String storagePath) async { try { - // ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase! - // Se il tuo storagePath contiene già il nome del bucket all'inizio, - // assicurati di passargli solo il percorso interno. final Uint8List bytes = await _supabase.storage - .from('attachments') // <--- NOME DEL TUO BUCKET + .from(_bucketName) .download(storagePath); - return bytes; } catch (e) { throw Exception("Impossibile scaricare il documento dal cloud: $e"); } } + + /// RESTITUISCE IL NOME DELLA COLONNA DB IN BASE AL TIPO + String _getColumnNameForParent(AttachmentParentType parentType) { + switch (parentType) { + case AttachmentParentType.operation: + return 'operation_id'; + case AttachmentParentType.ticket: + return 'ticket_id'; + case AttachmentParentType.customer: + return 'customer_id'; + } + } + + /// RECUPERA I FILE IN TEMPO REALE + Stream> getFilesStream( + String parentId, + AttachmentParentType parentType, + ) { + final columnName = _getColumnNameForParent(parentType); + + return _supabase + .from(_tableName) + .stream(primaryKey: ['id']) + .eq(columnName, parentId) + .map( + (listOfMaps) => + listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(), + ); + } + + /// CARICA IL FILE NELLO STORAGE E LO REGISTRA NEL DB + Future uploadAndRegisterFile({ + required String parentId, + required AttachmentParentType parentType, + required PlatformFile pickedFile, + }) async { + try { + final companyId = GetIt.I.get().state.company!.id!; + final extension = pickedFile.extension ?? pickedFile.name.split('.').last; + final cleanName = pickedFile.name + .replaceAll(RegExp(r'[^\w\s\.-]'), '') + .replaceAll(' ', '_'); + + // Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile + final timestamp = DateTime.now().millisecondsSinceEpoch; + final storagePath = + '$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName'; + + // 1. Upload su Supabase Storage + await _supabase.storage + .from(_bucketName) + .uploadBinary( + storagePath, + pickedFile.bytes!, + fileOptions: FileOptions( + upsert: true, + contentType: _guessContentType(extension), + ), + ); + + // 2. Creiamo la mappa per il DB dinamicamente + final Map insertData = { + 'company_id': companyId, + 'name': pickedFile.name.replaceAll('.$extension', ''), + 'extension': extension, + 'file_size': pickedFile.size, + 'storage_path': storagePath, + }; + + // Inseriamo l'ID nella colonna giusta! + final columnName = _getColumnNameForParent(parentType); + insertData[columnName] = parentId; + + // 3. Salviamo su Postgres + await _supabase.from(_tableName).insert(insertData); + } catch (e) { + throw Exception("Errore nel caricamento del file: $e"); + } + } + + /// ELIMINA IL FILE (Scollegamento intelligente) + Future deleteFiles({ + required List files, + required AttachmentParentType currentContextType, + }) async { + if (files.isEmpty) return; + + try { + for (var file in files) { + if (file.id == null) continue; + + // 1. Capiamo quali collegamenti ha questo file attualmente + final currentLinks = { + AttachmentParentType.operation: file.operationId, + AttachmentParentType.ticket: file.ticketId, + AttachmentParentType.customer: file.customerId, + }; + + // 2. Simuliamo la rimozione del collegamento per il contesto attuale + currentLinks[currentContextType] = null; + + // 3. Controlliamo se rimangono altri ID valorizzati + final hasOtherActiveLinks = currentLinks.values.any( + (id) => id != null && id.isNotEmpty, + ); + + if (hasOtherActiveLinks) { + // A. Ci sono ancora altre entità che usano questo file! + // Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna + await _supabase + .from(_tableName) + .update({currentContextType.dbColumn: null}) + .eq('id', file.id!); + } else { + // B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE. + await _supabase.from(_tableName).delete().eq('id', file.id!); + + if (file.storagePath != null) { + await _supabase.storage.from(_bucketName).remove([ + file.storagePath!, + ]); + } + } + } + } catch (e) { + throw Exception("Errore nell'eliminazione dei file: $e"); + } + } + + /// RINOMINA UN FILE (Solo nel DB, non cambiamo il file fisico) + Future renameAttachment(String fileId, String newName) async { + try { + await _supabase + .from(_tableName) + .update({'name': newName}) + .eq('id', fileId); + } catch (e) { + throw Exception("Errore nella rinomina del file: $e"); + } + } + + /// ASSOCIA UN FILE A UN'ALTRA ENTITÀ (Modifica il record esistente) + Future linkFileToEntity({ + required String fileId, + required AttachmentParentType targetType, + required String targetId, + }) async { + try { + // Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta + await _supabase + .from(_tableName) + .update({targetType.dbColumn: targetId}) + .eq('id', fileId); + } catch (e) { + throw Exception("Errore nel collegamento del file: $e"); + } + } + + // Helper per indovinare il content-type base + String _guessContentType(String extension) { + switch (extension.toLowerCase()) { + case 'pdf': + return 'application/pdf'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + return 'application/octet-stream'; + } + } } diff --git a/lib/features/attachments/models/attachment_model.dart b/lib/features/attachments/models/attachment_model.dart index 8e61b3e..a1e0eb2 100644 --- a/lib/features/attachments/models/attachment_model.dart +++ b/lib/features/attachments/models/attachment_model.dart @@ -7,6 +7,7 @@ class AttachmentModel extends Equatable { final DateTime? createdAt; final String? customerId; final String? operationId; + final String? ticketId; final String name; final String extension; final String? storagePath; @@ -19,6 +20,7 @@ class AttachmentModel extends Equatable { this.createdAt, this.customerId, this.operationId, + this.ticketId, required this.name, required this.extension, this.storagePath, @@ -33,6 +35,7 @@ class AttachmentModel extends Equatable { createdAt, customerId, operationId, + ticketId, name, extension, storagePath, @@ -59,6 +62,7 @@ class AttachmentModel extends Equatable { DateTime? createdAt, String? customerId, String? operationId, + String? ticketId, String? name, String? extension, String? storagePath, @@ -70,6 +74,7 @@ class AttachmentModel extends Equatable { createdAt: createdAt ?? this.createdAt, customerId: customerId ?? this.customerId, operationId: operationId ?? this.operationId, + ticketId: ticketId ?? this.ticketId, name: name ?? this.name, extension: extension ?? this.extension, storagePath: storagePath ?? this.storagePath, @@ -86,6 +91,7 @@ class AttachmentModel extends Equatable { : null, customerId: map['customer_id'] as String?, operationId: map['operation_id'] as String?, + ticketId: map['ticket_id'] as String?, name: map['name'] as String, extension: map['extension'] as String, storagePath: map['storage_path'] as String?, @@ -104,6 +110,7 @@ class AttachmentModel extends Equatable { 'storage_path': storagePath, 'customer_id': customerId, 'operation_id': operationId, + 'ticket_id': ticketId, 'file_size': fileSize, 'company_id': companyId, }; diff --git a/lib/features/operations/ui/operation_mobile_upload_screen.dart b/lib/features/operations/ui/operation_mobile_upload_screen.dart index ad916c7..2ff950c 100644 --- a/lib/features/operations/ui/operation_mobile_upload_screen.dart +++ b/lib/features/operations/ui/operation_mobile_upload_screen.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/attachments/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; @@ -36,10 +36,10 @@ class _OperationMobileUploadScreenState @override Widget build(BuildContext context) { - return BlocListener( + return BlocListener( listener: (context, state) { // Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina! - if (state.status == OperationFilesStatus.success && _isUploading) { + if (state.status == AttachmentsStatus.success && _isUploading) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Tutti i file caricati con successo! ✅"), @@ -47,7 +47,7 @@ class _OperationMobileUploadScreenState ); Navigator.of(context).pop(); } - if (state.status == OperationFilesStatus.failure) { + if (state.status == AttachmentsStatus.failure) { setState(() => _isUploading = false); ScaffoldMessenger.of( context, @@ -295,8 +295,8 @@ class _OperationMobileUploadScreenState // 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(UploadOperationFilesEvent(pickedFiles: _stagedFiles)); + final bloc = context.read(); + bloc.add(UploadAttachmentsEvent(pickedFiles: _stagedFiles)); // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! }