fantascenza attachment bloc agnostico, ora continuo refactor rimuovendo customer file bloc ecc.

This commit is contained in:
2026-05-06 10:17:48 +02:00
parent 5207a82706
commit ec06155f2b
10 changed files with 715 additions and 528 deletions

View File

@@ -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<StaffCubit>().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<ProductsCubit>().loadBrands();
context.read<StaffCubit>().loadStaffForStore(currentStoreId);
return BlocProvider(
create: (context) => OperationFilesBloc(operationId: operationId),
create: (context) => AttachmentsBloc(
parentId: operationId,
parentType: AttachmentParentType.operation,
),
child: OperationMobileUploadScreen(
operationId: operationId,
operationName: operationName,

View File

@@ -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<OperationFilesSection> {
if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
context.read<OperationFilesBloc>().add(
AddOperationFilesEvent(result.files),
);
context.read<AttachmentsBloc>().add(AddAttachmentsEvent(result.files));
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<OperationFilesBloc>();
final operationFilesBloc = context.read<AttachmentsBloc>();
Navigator.push(
context,
MaterialPageRoute(
@@ -107,10 +105,10 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
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<OperationFilesSection> {
final theme = Theme.of(context);
// USIAMO IL TUO BLOC!
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles;
@@ -442,7 +440,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
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<OperationFilesSection> {
),
onPressed: () {
if (selectedFiles.length == allFiles.length) {
context.read<OperationFilesBloc>().add(
ClearOperationFileSelectionEvent(),
context.read<AttachmentsBloc>().add(
ClearAttachmentSelectionEvent(),
);
} else {
context.read<OperationFilesBloc>().add(
SelectAllOperationFilesEvent(),
context.read<AttachmentsBloc>().add(
SelectAllAttachmentsEvent(),
);
}
},
@@ -477,7 +475,7 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
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<OperationFilesSection> {
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati',
onPressed: () {
context.read<OperationFilesBloc>().add(
DeleteOperationFilesEvent(),
context.read<AttachmentsBloc>().add(
DeleteAttachmentsEvent(),
);
},
),
@@ -505,9 +503,10 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
icon: const Icon(Icons.person_add, color: Colors.blue),
tooltip: 'Copia nei documenti del Cliente',
onPressed: () {
context.read<OperationFilesBloc>().add(
LinkFilesToCustomerEvent(
customerId: widget.currentOp.customerId!,
context.read<AttachmentsBloc>().add(
LinkAttachmentsToEntityEvent(
targetId: widget.currentOp.customerId!,
targetType: AttachmentParentType.customer,
),
);
ScaffoldMessenger.of(context).showSnackBar(
@@ -621,8 +620,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
onTap: () => _openFile(file),
onLongPress: () {
// Selezione rapida con long press!
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
borderRadius: BorderRadius.circular(8),
@@ -696,8 +695,8 @@ class _OperationFilesSectionState extends State<OperationFilesSection> {
right: 4,
child: InkWell(
onTap: () {
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
);
},
child: Container(

View File

@@ -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<AttachmentsEvent, AttachmentsState> {
final _repository = GetIt.I.get<AttachmentsRepository>();
AttachmentsBloc({String? parentId, required AttachmentParentType parentType})
: super(
AttachmentsState(
status: AttachmentsStatus.initial,
parentId: parentId,
parentType: parentType,
),
) {
on<ParentEntitySavedEvent>(_onParentEntitySaved);
on<LoadAttachmentsEvent>(_onLoadAttachments);
on<AddAttachmentsEvent>(_onAddAttachments);
on<UploadAttachmentsEvent>(_onUploadAttachments);
on<DeleteAttachmentsEvent>(_onDeleteAttachments);
on<ToggleAttachmentSelectionEvent>(_onToggleAttachmentSelection);
on<LinkAttachmentsToEntityEvent>(_onLinkAttachmentsToEntity);
on<RenameAttachmentEvent>(_onRenameAttachment);
on<DeleteSpecificAttachmentEvent>(_onDeleteSpecificAttachment);
on<SelectAllAttachmentsEvent>(_onSelectAllAttachments);
on<ClearAttachmentSelectionEvent>(_onClearAttachmentSelection);
// Se il BLoC nasce già con un ID, carichiamo i file
if (parentId != null) {
add(LoadAttachmentsEvent(parentId: parentId));
}
}
FutureOr<void> _onParentEntitySaved(
ParentEntitySavedEvent event,
Emitter<AttachmentsState> emit,
) async {
emit(
state.copyWith(
parentId: event.newParentId,
status: AttachmentsStatus.uploading,
),
);
if (state.localFiles.isNotEmpty) {
try {
final List<Future<void>> 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<void> _onLoadAttachments(
LoadAttachmentsEvent event,
Emitter<AttachmentsState> 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<AttachmentModel> data) => state.copyWith(
status: AttachmentsStatus.success,
remoteFiles: data,
),
onError: (error, stackTrace) => state.copyWith(
status: AttachmentsStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddAttachments(
AddAttachmentsEvent event,
Emitter<AttachmentsState> emit,
) async {
final currentId = state.parentId;
// BIVIO 1: PRATICA NUOVA (Salvataggio locale)
if (currentId == null) {
final companyId = GetIt.I.get<SessionCubit>().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<Future<void>> 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<void> _onUploadAttachments(
UploadAttachmentsEvent event,
Emitter<AttachmentsState> 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<Future<void>> 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<void> _onDeleteAttachments(
DeleteAttachmentsEvent event,
Emitter<AttachmentsState> 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<void> _onToggleAttachmentSelection(
ToggleAttachmentSelectionEvent event,
Emitter<AttachmentsState> emit,
) {
final selectedFiles = List<AttachmentModel>.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<AttachmentsState> emit,
) {
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
emit(state.copyWith(selectedFiles: state.allFiles));
}
void _onClearAttachmentSelection(
ClearAttachmentSelectionEvent event,
Emitter<AttachmentsState> emit,
) {
// Svuotiamo brutalmente la lista
emit(state.copyWith(selectedFiles: []));
}
FutureOr<void> _onLinkAttachmentsToEntity(
LinkAttachmentsToEntityEvent event,
Emitter<AttachmentsState> 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<Future<void>> 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<void> _onRenameAttachment(
RenameAttachmentEvent event,
Emitter<AttachmentsState> 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<void> _onDeleteSpecificAttachment(
DeleteSpecificAttachmentEvent event,
Emitter<AttachmentsState> emit,
) {
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles
.where((f) => f != event.file)
.toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
}
}
}

View File

@@ -0,0 +1,68 @@
part of 'attachments_bloc.dart';
abstract class AttachmentsEvent extends Equatable {
const AttachmentsEvent();
@override
List<Object?> 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<Object?> get props => [newParentId];
}
class LoadAttachmentsEvent extends AttachmentsEvent {
final String? parentId;
const LoadAttachmentsEvent({this.parentId});
}
class AddAttachmentsEvent extends AttachmentsEvent {
final List<PlatformFile> files;
const AddAttachmentsEvent(this.files);
}
class UploadAttachmentsEvent extends AttachmentsEvent {
final List<PlatformFile>? pickedFiles;
final List<XFile>? 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<Object?> 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);
}

View File

@@ -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<AttachmentModel> localFiles;
final List<AttachmentModel> remoteFiles;
final List<AttachmentModel> 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<AttachmentModel> localFiles;
final List<AttachmentModel> remoteFiles;
final List<AttachmentModel> selectedFiles;
@override
List<Object?> get props => [
operationId,
parentId,
parentType,
status,
error,
localFiles,
@@ -32,16 +43,18 @@ class OperationFilesState extends Equatable {
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
OperationFilesState copyWith({
String? operationId,
OperationFilesStatus? status,
AttachmentsState copyWith({
String? parentId,
AttachmentParentType? parentType,
AttachmentsStatus? status,
String? error,
List<AttachmentModel>? localFiles,
List<AttachmentModel>? remoteFiles,
List<AttachmentModel>? 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,

View File

@@ -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<OperationFilesEvent, OperationFilesState> {
final _repository = GetIt.I.get<OperationsRepository>();
final String? operationId;
OperationFilesBloc({this.operationId})
: super(
OperationFilesState(
status: OperationFilesStatus.initial,
operationId: operationId,
),
) {
on<OperationsavedEvent>(_onOperationsaved);
on<LoadOperationFilesEvent>(_onLoadOperationFiles);
on<AddOperationFilesEvent>(_onAddOperationFiles);
on<UploadOperationFilesEvent>(_onUploadOperationFiles);
on<DeleteOperationFilesEvent>(_onDeleteOperationFiles);
on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection);
on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer);
on<RenameOperationFileEvent>(_onRenameOperationFile);
on<DeleteSpecificOperationFileEvent>(_onDeleteSpecificOperationFiles);
on<SelectAllOperationFilesEvent>(_onSelectAllOperationFiles);
on<ClearOperationFileSelectionEvent>(_onClearOperationFileSelection);
// Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (operationId != null) {
add(LoadOperationFilesEvent(operationId: operationId));
}
}
FutureOr<void> _onOperationsaved(
OperationsavedEvent event,
Emitter<OperationFilesState> 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<Future<void>> 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<void> _onLoadOperationFiles(
LoadOperationFilesEvent event,
Emitter<OperationFilesState> 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<AttachmentModel> data) => state.copyWith(
status: OperationFilesStatus.success,
remoteFiles: data,
),
onError: (error, stackTrace) => state.copyWith(
status: OperationFilesStatus.failure,
error: error.toString(),
),
);
}
}
void _onAddOperationFiles(
AddOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
final currentId = state.operationId;
// BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale)
if (currentId == null) {
final companyId = GetIt.I.get<SessionCubit>().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<Future<void>> 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<void> _onUploadOperationFiles(
UploadOperationFilesEvent event,
Emitter<OperationFilesState> 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<Future<void>> 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<void> _onDeleteOperationFiles(
DeleteOperationFilesEvent event,
Emitter<OperationFilesState> 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<void> _onToggleOperationFileSelection(
ToggleOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit,
) {
final selectedFiles = List<AttachmentModel>.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<OperationFilesState> emit,
) {
// Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati
emit(state.copyWith(selectedFiles: state.allFiles));
}
void _onClearOperationFileSelection(
ClearOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit,
) {
// Svuotiamo brutalmente la lista
emit(state.copyWith(selectedFiles: []));
}
FutureOr<void> _onLinkFilesToCustomer(
LinkFilesToCustomerEvent event,
Emitter<OperationFilesState> 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<Future<void>> 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<void> _onRenameOperationFile(
RenameOperationFileEvent event,
Emitter<OperationFilesState> 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<void> _onDeleteSpecificOperationFiles(
DeleteSpecificOperationFileEvent event,
Emitter<OperationFilesState> emit,
) {
if (event.file.localBytes != null) {
final updatedLocalFiles = state.localFiles
.where((f) => f != event.file)
.toList();
emit(state.copyWith(localFiles: updatedLocalFiles));
}
}
}

View File

@@ -1,81 +0,0 @@
part of 'operation_files_bloc.dart';
abstract class OperationFilesEvent extends Equatable {
const OperationFilesEvent();
@override
List<Object?> get props => [];
}
class OperationsavedEvent extends OperationFilesEvent {
final String operationId;
const OperationsavedEvent(this.operationId);
@override
List<Object?> get props => [operationId];
}
class LoadOperationFilesEvent extends OperationFilesEvent {
final String? operationId;
final AttachmentModel? operation;
const LoadOperationFilesEvent({this.operationId, this.operation});
@override
List<Object?> get props => [operationId, operation];
}
class AddOperationFilesEvent extends OperationFilesEvent {
final List<PlatformFile> files;
const AddOperationFilesEvent(this.files);
@override
List<Object?> get props => [files];
}
class UploadOperationFilesEvent extends OperationFilesEvent {
final List<PlatformFile>? pickedFiles;
final List<XFile>? photos;
const UploadOperationFilesEvent({this.pickedFiles, this.photos});
@override
List<Object?> get props => [pickedFiles, photos];
}
class LinkFilesToCustomerEvent extends OperationFilesEvent {
final String customerId;
const LinkFilesToCustomerEvent({required this.customerId});
@override
List<Object?> 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<Object?> get props => [file, newName];
}
class DeleteSpecificOperationFileEvent extends OperationFilesEvent {
final AttachmentModel file;
const DeleteSpecificOperationFileEvent(this.file);
@override
List<Object?> get props => [file];
}
class SelectAllOperationFilesEvent extends OperationFilesEvent {}
class ClearOperationFileSelectionEvent extends OperationFilesEvent {}

View File

@@ -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<Uint8List> 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<List<AttachmentModel>> 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<void> uploadAndRegisterFile({
required String parentId,
required AttachmentParentType parentType,
required PlatformFile pickedFile,
}) async {
try {
final companyId = GetIt.I.get<SessionCubit>().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<String, dynamic> 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<void> deleteFiles({
required List<AttachmentModel> 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<void> 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<void> 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';
}
}
}

View File

@@ -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,
};

View File

@@ -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<OperationFilesBloc, OperationFilesState>(
return BlocListener<AttachmentsBloc, AttachmentsState>(
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<OperationFilesBloc>();
bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles));
final bloc = context.read<AttachmentsBloc>();
bloc.add(UploadAttachmentsEvent(pickedFiles: _stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
}