feat-tickets (#14)
Some checks failed
Deploy to Cloudflare Pages / build-and-deploy (push) Has been cancelled

Reviewed-on: #14
Co-authored-by: mark-cachy <marco@catelli.it>
Co-committed-by: mark-cachy <marco@catelli.it>
This commit is contained in:
2026-05-07 16:28:01 +02:00
committed by brontomark
parent 94ad524bae
commit 7d03d0dea5
38 changed files with 3594 additions and 1486 deletions

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,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 = 'documents';
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,139 +0,0 @@
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/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:get_it/get_it.dart';
part 'customer_files_events.dart';
part 'customer_files_state.dart';
class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
final CustomerRepository _repository = GetIt.I<CustomerRepository>();
final String customerId;
CustomerFilesBloc(this.customerId)
: super(const CustomerFilesState(status: CustomerFilesStatus.initial)) {
on<LoadCustomerFilesEvent>(_loadCustomerFiles);
on<UploadCustomerFileEvent>(_uploadCustomerFile);
on<UploadMultipleCustomerFilesEvent>(_uploadMultipleCustomerFiles);
on<DeleteCustomerFilesEvent>(_deleteCustomerFiles);
on<ToggleCustomerFileSelectionEvent>(_toggleCustomerFileSelection);
}
void _loadCustomerFiles(
LoadCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
await emit.forEach<List<AttachmentModel>>(
_repository.getCustomerFilesStream(customerId),
onData: (customerFiles) => CustomerFilesState(
status: CustomerFilesStatus.success,
customerFiles: customerFiles,
),
onError: (error, stackTrace) => CustomerFilesState(
status: CustomerFilesStatus.failure,
error: error.toString(),
),
);
}
Future<void> _uploadCustomerFile(
UploadCustomerFileEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.uploading));
if (event.pickedFile != null) {
try {
await _repository.uploadAndRegisterFile(
customerId: customerId,
pickedFile: event.pickedFile!,
);
emit(state.copyWith(status: CustomerFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
}
FutureOr<void> _uploadMultipleCustomerFiles(
UploadMultipleCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return;
}
emit(state.copyWith(status: CustomerFilesStatus.uploading, error: null));
try {
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro!
uploadTasks.add(
_repository.uploadAndRegisterFile(
customerId: customerId,
pickedFile: file,
),
);
}
// 3. ESECUZIONE PARALLELA!
// Aspettiamo che tutti i file siano caricati contemporaneamente.
await Future.wait(uploadTasks);
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
emit(state.copyWith(status: CustomerFilesStatus.success));
} catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e",
),
);
}
}
Future<void> _deleteCustomerFiles(
DeleteCustomerFilesEvent event,
Emitter<CustomerFilesState> emit,
) async {
emit(state.copyWith(status: CustomerFilesStatus.loading));
try {
await _repository.deleteDocuments(state.selectedFiles);
emit(
state.copyWith(status: CustomerFilesStatus.success, selectedFiles: []),
);
} catch (e) {
emit(
state.copyWith(
status: CustomerFilesStatus.failure,
error: e.toString(),
),
);
}
}
void _toggleCustomerFileSelection(
ToggleCustomerFileSelectionEvent event,
Emitter<CustomerFilesState> emit,
) {
List<AttachmentModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file);
} else {
selectedFiles.add(event.file);
}
emit(state.copyWith(selectedFiles: selectedFiles));
}
}

View File

@@ -1,30 +0,0 @@
part of 'customer_files_bloc.dart';
abstract class CustomerFilesEvent extends Equatable {
const CustomerFilesEvent();
@override
List<Object> get props => [];
}
class LoadCustomerFilesEvent extends CustomerFilesEvent {}
class UploadCustomerFileEvent extends CustomerFilesEvent {
final PlatformFile? pickedFile;
final File? photo;
const UploadCustomerFileEvent({this.pickedFile, this.photo});
}
class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
final List<PlatformFile> files;
const UploadMultipleCustomerFilesEvent(this.files);
@override
List<Object> get props => [files];
}
class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
final AttachmentModel file;
const ToggleCustomerFileSelectionEvent(this.file);
}

View File

@@ -1,34 +0,0 @@
part of 'customer_files_bloc.dart';
enum CustomerFilesStatus { initial, loading, uploading, success, failure }
class CustomerFilesState extends Equatable {
const CustomerFilesState({
required this.status,
this.error,
this.customerFiles = const [],
this.selectedFiles = const [],
});
final CustomerFilesStatus status;
final String? error;
final List<AttachmentModel> customerFiles;
final List<AttachmentModel> selectedFiles;
@override
List<Object?> get props => [status, error, customerFiles, selectedFiles];
CustomerFilesState copyWith({
CustomerFilesStatus? status,
String? error,
List<AttachmentModel>? customerFiles,
List<AttachmentModel>? selectedFiles,
}) {
return CustomerFilesState(
status: status ?? this.status,
error: error,
customerFiles: customerFiles ?? this.customerFiles,
selectedFiles: selectedFiles ?? this.selectedFiles,
);
}
}

View File

@@ -6,8 +6,8 @@ import 'package:flux/core/theme/theme.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/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/customers/models/customer_model.dart';
class CustomerDetailScreen extends StatefulWidget {
@@ -26,11 +26,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
}
void _loadFiles() {
context.read<CustomerFilesBloc>().add(LoadCustomerFilesEvent());
context.read<AttachmentsBloc>().add(
LoadAttachmentsEvent(parentId: widget.customer.id),
);
}
Future<void> _pickAndUpload() async {
CustomerFilesBloc customerFilesBloc = context.read<CustomerFilesBloc>();
AttachmentsBloc attachmentsBloc = context.read<AttachmentsBloc>();
// Chiamata statica pulita
FilePickerResult? result = await FilePicker.pickFiles(
@@ -40,17 +42,13 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
);
if (result != null) {
for (var pickedFile in result.files) {
try {
customerFilesBloc.add(
UploadCustomerFileEvent(pickedFile: pickedFile),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")),
);
}
try {
attachmentsBloc.add(UploadAttachmentsEvent(pickedFiles: result.files));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("$e")));
}
}
}
@@ -143,7 +141,7 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
}
Widget _buildDocumentSection() {
return BlocBuilder<CustomerFilesBloc, CustomerFilesState>(
return BlocBuilder<AttachmentsBloc, AttachmentsState>(
builder: (context, state) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -213,9 +211,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
],
),
const SizedBox(height: 20),
if (state.status == CustomerFilesStatus.loading)
if (state.status == AttachmentsStatus.loading)
const Center(child: CircularProgressIndicator())
else if (state.customerFiles.isEmpty)
else if (state.allFiles.isEmpty)
const Center(child: Text("Nessun documento presente"))
else
Expanded(
@@ -226,9 +224,9 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
crossAxisSpacing: 16,
childAspectRatio: 1.2,
),
itemCount: state.customerFiles.length,
itemCount: state.allFiles.length,
itemBuilder: (context, index) =>
_FileCard(file: state.customerFiles[index], state: state),
_FileCard(file: state.allFiles[index], state: state),
),
),
],
@@ -268,14 +266,14 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
class _FileCard extends StatelessWidget {
final AttachmentModel file;
final CustomerFilesState state;
final AttachmentsState state;
const _FileCard({required this.file, required this.state});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.read<CustomerFilesBloc>().add(
ToggleCustomerFileSelectionEvent(file),
onTap: () => context.read<AttachmentsBloc>().add(
ToggleAttachmentSelectionEvent(file),
),
onDoubleTap: () => _handleDoubleClickOnFile(context, file),
child: Stack(

View File

@@ -1,304 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
class CustomerMobileUploadScreen extends StatefulWidget {
final String customerId;
final String customerName;
const CustomerMobileUploadScreen({
super.key,
required this.customerId,
required this.customerName,
});
@override
State<CustomerMobileUploadScreen> createState() =>
_CustomerMobileUploadScreenState();
}
class _CustomerMobileUploadScreenState
extends State<CustomerMobileUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = [];
// 2. STATO DI CARICAMENTO GLOBALE
bool _isUploading = false;
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
bool _isImage(String path) {
final ext = path.split('.').last.toLowerCase();
return ['jpg', 'jpeg', 'png', 'webp'].contains(ext);
}
@override
Widget build(BuildContext context) {
return BlocListener<CustomerFilesBloc, CustomerFilesState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == CustomerFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
}
if (state.status == CustomerFilesStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
}
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload: ${widget.customerName}"),
// Togliamo la freccia indietro se stiamo caricando per evitare disastri
automaticallyImplyLeading: !_isUploading,
),
body: Stack(
children: [
Column(
children: [
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isUploading ? null : _handleCamera,
icon: const Icon(Icons.camera_alt),
label: const Text("SCATTA"),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isUploading ? null : _handleFilePicker,
icon: const Icon(Icons.folder),
label: const Text("GALLERIA"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
),
const Divider(),
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
Expanded(
child: _stagedFiles.isEmpty
? const Center(
child: Text(
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
3, // 3 colonne come la galleria dell'iPhone
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _stagedFiles.length,
itemBuilder: (context, index) {
final file = _stagedFiles[index];
final isImg = _isImage(file.name);
return Stack(
clipBehavior: Clip.none,
children: [
// L'ANTEPRIMA
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isImg
? Image.file(
File(file.path!),
fit: BoxFit.cover,
)
: const Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 36,
),
SizedBox(height: 4),
Text(
"PDF",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
// IL PULSANTE CESTINO (In alto a destra)
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () {
setState(() {
_stagedFiles.removeAt(index);
});
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
},
),
),
// --- SEZIONE INVIA E CHIUDI ---
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
// Il pulsante si accende SOLO se ci sono file nel carrello
onPressed: _stagedFiles.isEmpty || _isUploading
? null
: _submitAllFiles,
icon: const Icon(Icons.cloud_upload),
label: Text(
"INVIA ${_stagedFiles.length} FILE E CHIUDI",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
),
),
],
),
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
if (_isUploading)
Container(
color: Colors.black.withValues(alpha: 0.5),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
"Caricamento in corso...",
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
),
),
),
],
),
),
);
}
// --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera() async {
final picker = ImagePicker();
final photo = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (photo != null) {
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
final photoSize = await photo.length();
final platformFile = PlatformFile(
name: photo.name,
size: photoSize,
path: photo.path,
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
);
setState(() {
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
});
}
}
Future<void> _handleFilePicker() async {
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
final result = await FilePicker.pickFiles(allowMultiple: true);
if (result != null) {
setState(() {
_stagedFiles.addAll(result.files);
});
}
}
// --- LOGICA DI INVIO AL BLoC ---
void _submitAllFiles() {
setState(() => _isUploading = true);
// Diciamo al BLoC di caricare tutti i file.
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
final bloc = context.read<CustomerFilesBloc>();
bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
}
}

View File

@@ -47,30 +47,30 @@ class _LatestOperationsCardContent extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DELLA CARD ---
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
child: InkWell(
onTap: () => context.push('/operations'),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// --- HEADER DELLA CARD ---
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.design_services_outlined,
color: color,
size: 20,
),
),
child: const Icon(
Icons.design_services_outlined,
color: color,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: TextButton(
onPressed: () => context.push('/operations'),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.homeLatestOperations,
style: TextStyle(
@@ -82,106 +82,111 @@ class _LatestOperationsCardContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
),
),
],
),
const SizedBox(height: 12),
],
),
const SizedBox(height: 12),
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
Expanded(
child:
BlocBuilder<
LatestStoreOperationsBloc,
LatestStoreOperationsState
>(
builder: (context, state) {
if (state.status == LatestStoreOperationsStatus.loading ||
state.status == LatestStoreOperationsStatus.initial) {
return const Center(child: CircularProgressIndicator());
}
// --- CORPO DELLA CARD (LA LISTA REAL-TIME) ---
Expanded(
child:
BlocBuilder<
LatestStoreOperationsBloc,
LatestStoreOperationsState
>(
builder: (context, state) {
if (state.status ==
LatestStoreOperationsStatus.loading ||
state.status ==
LatestStoreOperationsStatus.initial) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (state.status == LatestStoreOperationsStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
);
}
if (state.operations.isEmpty) {
return Center(
child: Text(
"Nessun servizio recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
if (state.status ==
LatestStoreOperationsStatus.failure) {
return Center(
child: Text(
"Errore di caricamento",
style: TextStyle(color: theme.colorScheme.error),
),
),
);
}
);
}
return ListView.separated(
itemCount: state.operations.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final operation = state.operations[index];
return InkWell(
onTap: () => context.push(
'/operation-form',
extra: operation,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 5,
child: Text(
operation.customerDisplayName ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
operation.reference,
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${operation.createdAt?.day}/${operation.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
if (state.operations.isEmpty) {
return Center(
child: Text(
"Nessun servizio recente.",
style: TextStyle(
color: context.secondaryText,
fontStyle: FontStyle.italic,
),
),
);
},
);
},
),
),
],
}
return ListView.separated(
itemCount: state.operations.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final operation = state.operations[index];
return InkWell(
onTap: () => context.push(
'/operation-form',
extra: operation,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
flex: 5,
child: Text(
operation.customerDisplayName ??
'Cliente sconosciuto',
style: TextStyle(
fontWeight: FontWeight.w700,
color: context.primaryText,
),
),
),
Expanded(
flex: 5,
child: Text(
operation.reference,
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(
"${operation.createdAt?.day}/${operation.createdAt?.month}",
style: TextStyle(
color: context.secondaryText,
fontSize: 12,
),
),
],
),
),
);
},
);
},
),
),
],
),
),
),
);

View File

@@ -77,12 +77,13 @@ class HomeScreen extends StatelessWidget {
context: context,
),
LatestStoreOperationsCard(),
_buildDashboardWidget(
title: context.l10n.homeLatestOperationTickets,
icon: Icons.support_agent_outlined,
color: Colors.purple,
context: context,
onTap: () =>
context.push('/tickets'), // <-- Aggiunto!
),
]),
),
@@ -194,8 +195,8 @@ class HomeScreen extends StatelessWidget {
label: context.l10n.homeNewOperationTicket,
color: Colors.redAccent,
onTap: () {
// TODO: Quando avrai la rotta per la nuova assistenza
// context.push('/assistance-form');
// Andiamo alla lista! (Da lì poi aggiungeremo il tasto "+" per il form)
context.push('/tickets/form/new');
},
),
const SizedBox(width: 12),
@@ -226,68 +227,73 @@ class HomeScreen extends StatelessWidget {
required String title,
required IconData icon,
required Color color,
VoidCallback? onTap,
}) {
final theme = Theme.of(context);
return Card(
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Icon(icon, color: color, size: 20),
),
),
IconButton(
icon: Icon(
Icons.more_vert,
size: 20,
color: context.secondaryText,
const SizedBox(width: 12),
Expanded(
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: context.primaryText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
Icons.more_vert,
size: 20,
color: context.secondaryText,
),
onPressed: () {},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const Spacer(),
Center(
child: Text(
context.l10n.commonComingSoon,
style: TextStyle(
color: context.secondaryText.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
fontSize: 13,
),
onPressed: () {},
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const Spacer(),
Center(
child: Text(
context.l10n.commonComingSoon,
style: TextStyle(
color: context.secondaryText.withValues(alpha: 0.7),
fontStyle: FontStyle.italic,
fontSize: 13,
),
),
),
const Spacer(),
],
const Spacer(),
],
),
),
),
);

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,14 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/widgets/customer_section.dart';
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
import 'package:flux/features/operations/ui/widgets/details_section.dart';
import 'package:flux/features/operations/ui/widgets/operation_files_section.dart';
import 'package:flux/features/operations/ui/widgets/staff_section.dart';
import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH
// import 'package:flux/features/attachments/ui/operation_files_section.dart';
import 'package:flux/core/widgets/shared_forms/attachments_section.dart';
import 'package:flux/core/widgets/shared_forms/staff_section.dart';
import 'package:get_it/get_it.dart';
class OperationFormScreen extends StatefulWidget {
final String? operationId;
@@ -216,8 +216,9 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
flex: 3,
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: OperationFilesSection(
currentOp: state.currentOperation!,
child: SharedAttachmentsSection(
parentType: AttachmentParentType.operation,
parentId: state.currentOperation?.id,
),
),
),
@@ -317,10 +318,28 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StaffSection(currentOp: currentOp),
StaffSection(
staffId: currentOp?.staffId,
staffName: currentOp?.staffDisplayName,
onStaffSelected: (staff) => {
context.read<OperationsCubit>().updateOperationFields(
staffId: staff.id,
staffDisplayName: staff.name,
),
},
),
const Divider(height: 50),
_buildSectionTitle('Cliente & Riferimento'),
CustomerSection(currentOp: currentOp),
SharedCustomerSection(
customerId: currentOp?.customerId,
customerName: currentOp?.customerDisplayName,
onCustomerSelected: (customer) {
context.read<OperationsCubit>().updateOperationFields(
customerId: customer.id,
customerDisplayName: customer.name,
);
},
),
const SizedBox(height: 16),
TextFormField(
controller: _referenceController,
@@ -390,7 +409,12 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
),
const Divider(height: 32),
if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
if (showFiles) ...[
SharedAttachmentsSection(
parentType: AttachmentParentType.operation,
parentId: currentOp?.id,
),
],
],
);
}

View File

@@ -1,303 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
class OperationMobileUploadScreen extends StatefulWidget {
final String operationId;
final String operationName;
const OperationMobileUploadScreen({
super.key,
required this.operationId,
required this.operationName,
});
@override
State<OperationMobileUploadScreen> createState() =>
_OperationMobileUploadScreenState();
}
class _OperationMobileUploadScreenState
extends State<OperationMobileUploadScreen> {
// 1. LA NOSTRA STAGING AREA (Il "Carrello")
final List<PlatformFile> _stagedFiles = [];
// 2. STATO DI CARICAMENTO GLOBALE
bool _isUploading = false;
// Funzione magica per capire se è un'immagine o un PDF dall'estensione
bool _isImage(String path) {
final ext = path.split('.').last.toLowerCase();
return ['jpg', 'jpeg', 'png', 'webp'].contains(ext);
}
@override
Widget build(BuildContext context) {
return BlocListener<OperationFilesBloc, OperationFilesState>(
listener: (context, state) {
// Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina!
if (state.status == OperationFilesStatus.success && _isUploading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Tutti i file caricati con successo! ✅"),
),
);
Navigator.of(context).pop();
}
if (state.status == OperationFilesStatus.failure) {
setState(() => _isUploading = false);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text("Errore: ${state.error}")));
}
},
child: Scaffold(
appBar: AppBar(
title: Text("Upload Pratica:\n${widget.operationName}"),
automaticallyImplyLeading: !_isUploading,
),
body: Stack(
children: [
Column(
children: [
// --- SEZIONE PULSANTI (Fotocamera / Galleria) ---
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isUploading ? null : _handleCamera,
icon: const Icon(Icons.camera_alt),
label: const Text("SCATTA"),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton.icon(
onPressed: _isUploading ? null : _handleFilePicker,
icon: const Icon(Icons.folder),
label: const Text("GALLERIA"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
),
const Divider(),
// --- SEZIONE ANTEPRIME (La GridView Magica) ---
Expanded(
child: _stagedFiles.isEmpty
? const Center(
child: Text(
"Nessun file selezionato.\nScatta una foto o scegli dalla galleria.",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount:
3, // 3 colonne come la galleria dell'iPhone
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _stagedFiles.length,
itemBuilder: (context, index) {
final file = _stagedFiles[index];
final isImg = _isImage(file.name);
return Stack(
clipBehavior: Clip.none,
children: [
// L'ANTEPRIMA
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: isImg
? Image.file(
File(file.path!),
fit: BoxFit.cover,
)
: const Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.picture_as_pdf,
color: Colors.red,
size: 36,
),
SizedBox(height: 4),
Text(
"PDF",
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
// IL PULSANTE CESTINO (In alto a destra)
Positioned(
top: -8,
right: -8,
child: GestureDetector(
onTap: () {
setState(() {
_stagedFiles.removeAt(index);
});
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: 16,
),
),
),
),
],
);
},
),
),
// --- SEZIONE INVIA E CHIUDI ---
SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
// Il pulsante si accende SOLO se ci sono file nel carrello
onPressed: _stagedFiles.isEmpty || _isUploading
? null
: _submitAllFiles,
icon: const Icon(Icons.cloud_upload),
label: Text(
"INVIA ${_stagedFiles.length} FILE E CHIUDI",
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Theme.of(
context,
).colorScheme.onPrimary,
),
),
),
),
),
],
),
// --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) ---
if (_isUploading)
Container(
color: Colors.black.withValues(alpha: 0.5),
child: const Center(
child: Card(
child: Padding(
padding: EdgeInsets.all(24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text(
"Caricamento in corso...",
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
),
),
),
],
),
),
);
}
// --- LOGICA FOTOCAMERA E LIBRERIA ---
Future<void> _handleCamera() async {
final picker = ImagePicker();
final photo = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (photo != null) {
final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web!
final photoSize = await photo.length();
final platformFile = PlatformFile(
name: photo.name,
size: photoSize,
path: photo.path,
bytes: photoBytes, // I bytes ci salvano la vita su Supabase!
);
setState(() {
_stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File
});
}
}
Future<void> _handleFilePicker() async {
// allowMultiple: true permette di pescare 5 foto dalla galleria in un colpo solo!
final result = await FilePicker.pickFiles(allowMultiple: true);
if (result != null) {
setState(() {
_stagedFiles.addAll(result.files);
});
}
}
// --- LOGICA DI INVIO AL BLoC ---
void _submitAllFiles() {
setState(() => _isUploading = true);
// Diciamo al BLoC di caricare tutti i file.
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
final bloc = context.read<OperationFilesBloc>();
bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
}
}

View File

@@ -1,222 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/customers/blocs/customers_cubit.dart';
import 'package:flux/features/customers/ui/quick_customer_dialog.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
class CustomerSection extends StatelessWidget {
final OperationModel? currentOp;
const CustomerSection({super.key, required this.currentOp});
@override
Widget build(BuildContext context) {
final hasCustomer =
currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty;
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Cliente',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
InkWell(
onTap: () => _showCustomerModal(context), // Passiamo il context!
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.primary),
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.2),
),
child: Row(
children: [
const Icon(Icons.person),
const SizedBox(width: 12),
Expanded(
child: Text(
hasCustomer
? currentOp!.customerDisplayName!
: 'Seleziona Cliente *',
style: TextStyle(
fontWeight: hasCustomer
? FontWeight.bold
: FontWeight.normal,
color: hasCustomer ? null : Colors.grey,
),
),
),
const Icon(Icons.search),
],
),
),
),
],
);
}
// --- MODALE SELEZIONE CLIENTE ---
void _showCustomerModal(BuildContext context) {
String currentSearchQuery = '';
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.8,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
// Header
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Cliente',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
// Barra di Ricerca
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
autofocus: true,
decoration: InputDecoration(
hintText: 'Cerca per nome, telefono o email...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) {
currentSearchQuery = query;
context.read<CustomersCubit>().searchCustomers(query);
},
),
),
// Pulsante Nuovo Cliente
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.person_add),
label: const Text('Crea Nuovo Cliente'),
onPressed: () async {
final OperationsCubit operationsCubit = context
.read<OperationsCubit>();
// APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER
final newCustomer = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<CustomersCubit>(),
child: QuickCustomerDialog(
initialQuery:
currentSearchQuery, // <-- Passiamo quello che ha digitato!
),
);
},
);
// Se l'ha creato davvero (e non ha premuto annulla)...
if (newCustomer != null) {
// 1. Aggiorniamo il form delle operazioni
operationsCubit.updateOperationFields(
customerId: newCustomer.id,
customerDisplayName: newCustomer.name,
);
// 2. Chiudiamo la BottomSheet dei clienti per tornare alla form!
if (context.mounted) {
Navigator.pop(modalContext);
}
}
},
),
),
const Divider(),
// Lista Clienti dal Bloc
Expanded(
child: BlocBuilder<CustomersCubit, CustomersState>(
builder: (context, state) {
if (state.status == CustomersStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.customers.isEmpty) {
return const Center(
child: Text(
'Nessun cliente trovato.',
style: TextStyle(color: Colors.grey),
),
);
}
return ListView.builder(
controller: scrollController,
itemCount: state.customers.length,
itemBuilder: (context, index) {
final customer = state.customers[index];
return ListTile(
leading: CircleAvatar(
child: Text(
customer.name.substring(0, 1).toUpperCase(),
),
),
title: Text(
customer.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
'${customer.phoneNumber}${customer.email}',
),
onTap: () {
// Aggiorniamo il form tramite il Cubit delle operazioni
context
.read<OperationsCubit>()
.updateOperationFields(
customerId: customer.id, // customer.id
customerDisplayName:
customer.name, // customer.name
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/master_data/products/blocs/product_cubit.dart';
import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart';
import 'package:flux/core/widgets/shared_forms/model_section.dart';
import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
@@ -140,129 +139,6 @@ class DetailsSection extends StatelessWidget {
);
}
void _showModelModal(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (modalContext) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
expand: false,
builder: (_, scrollController) {
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Seleziona Modello',
style: Theme.of(context).textTheme.titleLarge,
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(modalContext),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
decoration: InputDecoration(
hintText: 'Cerca modello (es. iPhone 15...)',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (query) =>
context.read<ProductsCubit>().searchModels(query),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
icon: const Icon(Icons.add),
label: const Text('Aggiungi Modello al Volo'),
onPressed: () async {
final operationsCubit = context.read<OperationsCubit>();
final existingBrands = context
.read<ProductsCubit>()
.state
.brands;
final newModel = await showDialog(
context: context,
builder: (dialogContext) {
return BlocProvider.value(
value: context.read<ProductsCubit>(),
child: QuickProductDialog(
existingBrands: existingBrands,
),
);
},
);
if (newModel != null) {
operationsCubit.updateOperationFields(
modelId: newModel.id,
modelDisplayName: newModel.nameWithBrand,
);
if (context.mounted) Navigator.pop(modalContext);
}
},
),
),
const Divider(),
Expanded(
child: BlocBuilder<ProductsCubit, ProductState>(
builder: (context, state) {
return ListView.builder(
controller: scrollController,
itemCount: state.models.length,
itemBuilder: (context, index) {
final deviceModel = state.models[index];
return ListTile(
leading: const Icon(Icons.devices),
title: Text(
deviceModel.nameWithBrand,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
onTap: () {
context
.read<OperationsCubit>()
.updateOperationFields(
modelId: deviceModel.id,
modelDisplayName: deviceModel.nameWithBrand,
);
Navigator.pop(modalContext);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -334,30 +210,16 @@ class DetailsSection extends StatelessWidget {
// 2. SCENARIO FIN (Ricerca Modello/Prodotto)
if (currentType == 'Fin') ...[
ListTile(
title: const Text('Seleziona Dispositivo/Prodotto'),
subtitle: Text(
(currentOp?.modelDisplayName != null &&
currentOp!.modelDisplayName!.isNotEmpty)
? currentOp!.modelDisplayName!
: 'Nessun modello selezionato',
style: TextStyle(
color:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? Colors.grey
: null,
fontWeight:
(currentOp?.modelId == null || currentOp!.modelId!.isEmpty)
? FontWeight.normal
: FontWeight.bold,
),
),
trailing: const Icon(Icons.arrow_drop_down),
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
onTap: () => _showModelModal(context),
SharedModelSection(
label: 'Seleziona Dispositivo/Prodotto',
modelId: currentOp?.modelId,
modelName: currentOp?.modelDisplayName,
onModelSelected: (id, name) {
context.read<OperationsCubit>().updateOperationFields(
modelId: id,
modelDisplayName: name,
);
},
),
const SizedBox(height: 16),
],

View File

@@ -1,761 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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/operations/blocs/operation_files_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';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:pdf/pdf.dart' as p; // Se ti serve formattazione core
import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx
class _ExportItem {
final Uint8List bytes;
final String sourceName;
final bool isMultiPage;
final int pageIndex;
_ExportItem({
required this.bytes,
required this.sourceName,
required this.isMultiPage,
required this.pageIndex,
});
}
class OperationFilesSection extends StatefulWidget {
final OperationModel currentOp;
const OperationFilesSection({super.key, required this.currentOp});
@override
State<OperationFilesSection> createState() => _OperationFilesSectionState();
}
class _OperationFilesSectionState extends State<OperationFilesSection> {
String? _exportDirectory;
@override
void initState() {
super.initState();
_loadExportDirectory();
}
// --- GESTIONE CARTELLA CITRIX (SOLO DESKTOP) ---
Future<void> _loadExportDirectory() async {
if (kIsWeb) return;
final prefs = await SharedPreferences.getInstance();
setState(() {
_exportDirectory = prefs.getString('citrix_export_path');
});
}
Future<void> _selectExportDirectory() async {
final String? selectedDirectory = await FilePicker.getDirectoryPath(
dialogTitle: 'Seleziona la cartella di esportazione per TIM/Citrix',
);
if (selectedDirectory != null) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('citrix_export_path', selectedDirectory);
setState(() {
_exportDirectory = selectedDirectory;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Cartella Export impostata: $selectedDirectory'),
),
);
}
}
}
// --- SELEZIONE FILE DAL PC/TELEFONO ---
Future<void> _pickFiles() async {
final result = await FilePicker.pickFiles(
allowMultiple: true,
type: FileType.custom,
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'],
withData: true,
);
if (result != null && mounted) {
// MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC!
context.read<OperationFilesBloc>().add(
AddOperationFilesEvent(result.files),
);
}
}
// --- APERTURA VIEWER ---
void _openFile(AttachmentModel file) {
// 1. Catturiamo il BLoC dalla pagina corrente prima di navigare
final operationFilesBloc = context.read<OperationFilesBloc>();
Navigator.push(
context,
MaterialPageRoute(
builder: (viewerContext) => BlocProvider.value(
value: operationFilesBloc,
child: AttachmentViewerScreen(
attachment: file,
onRename: (newName) {
// Spara l'evento al BLoC e lui farà il resto!
operationFilesBloc.add(RenameOperationFileEvent(file, newName));
},
onDelete: () {
operationFilesBloc.add(DeleteSpecificOperationFileEvent(file));
},
),
),
),
);
}
Future<void> _exportMergedPdf(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" DI TUTTO (Stessa magia di prima)
List<Uint8List> allPagesAsImages = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
allPagesAsImages.add(pageImage.bytes);
}
await page.close();
}
await document.close();
} else {
// È un'immagine
allPagesAsImages.add(fileBytes);
}
}
if (mounted) Navigator.pop(context); // Togliamo il loading
// Se per qualche motivo la lista è vuota, usciamo
if (allPagesAsImages.isEmpty) return;
// 2. LOGICA DEL NOME SUGGERITO
String suggestedName;
if (selectedFiles.length == 1) {
// Se c'è un solo file (es. ho selezionato 3 foto ma poi ho deselezionato le altre)
suggestedName = selectedFiles.first.name;
} else {
// Se sono più file uniti
suggestedName = '${widget.currentOp.customerDisplayName}_Unito';
}
if (!mounted) return;
// 3. DIALOG DI CONFERMA (Mostriamo la PRIMA pagina come anteprima per fargli capire cos'è)
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(
allPagesAsImages.first,
fit: BoxFit.contain,
),
),
);
if (finalName == null || finalName.isEmpty) return; // Ha annullato
// 4. CREAZIONE DEL PDF UNICO (IL MERGE VERO E PROPRIO)
final pdf = pw.Document();
// Cicliamo su tutte le immagini estratte e creiamo una pagina per ognuna
for (var imageBytes in allPagesAsImages) {
final pdfImage = pw.MemoryImage(imageBytes);
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
}
final mergedPdfBytes = await pdf.save();
// 5. SALVATAGGIO SUL DISCO
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(mergedPdfBytes);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('PDF Multi-pagina creato e salvato con successo!'),
),
);
}
} catch (e) {
if (mounted) {
// Se il loading è ancora aperto, lo chiudiamo
if (Navigator.canPop(context)) Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore durante l\'unione: $e')));
}
}
}
Future<void> _exportSplitPdfs(List<AttachmentModel> selectedFiles) async {
if (!kIsWeb && (_exportDirectory == null || _exportDirectory!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Imposta prima la cartella Citrix!')),
);
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
try {
// 1. "FLATTEN" INTELLIGENTE (Ora usiamo la nostra classe _ExportItem)
List<_ExportItem> itemsToExport = [];
final repository = GetIt.I.get<AttachmentsRepository>();
for (var file in selectedFiles) {
Uint8List? fileBytes;
if (file.localBytes != null) {
fileBytes = file.localBytes;
} else if (file.storagePath != null && file.storagePath!.isNotEmpty) {
fileBytes = await repository.downloadAttachmentBytes(
file.storagePath!,
);
}
if (fileBytes == null) continue;
// Recuperiamo il nome che l'utente ha (magari) già impostato
final baseName = file.name ?? 'Documento';
if (file.extension == 'pdf') {
final document = await px.PdfDocument.openData(fileBytes);
final isMulti =
document.pagesCount > 1; // Controlliamo se è multipagina!
for (int i = 1; i <= document.pagesCount; i++) {
final page = await document.getPage(i);
final pageImage = await page.render(
width: page.width * 2,
height: page.height * 2,
format: px.PdfPageImageFormat.jpeg,
);
if (pageImage != null) {
// Salviamo l'immagine CON il suo contesto storico
itemsToExport.add(
_ExportItem(
bytes: pageImage.bytes,
sourceName: baseName,
isMultiPage: isMulti,
pageIndex: i,
),
);
}
await page.close();
}
await document.close();
} else {
// SE È UN'IMMAGINE, la salviamo come singola pagina
itemsToExport.add(
_ExportItem(
bytes: fileBytes,
sourceName: baseName,
isMultiPage: false,
pageIndex: 1,
),
);
}
}
if (mounted) Navigator.pop(context);
// 2. IL CICLO UX
for (var item in itemsToExport) {
if (!mounted) return;
// LA TUA MAGIA UX SUI NOMI:
// Se è singolo (foto o PDF da 1 pag) -> Usa il nome originale nudo e crudo!
// Se è multipagina -> Usa il nome originale + il numero di pagina
String suggestedName = item.sourceName;
if (item.isMultiPage) {
suggestedName = '${item.sourceName}_Pag_${item.pageIndex}';
}
final finalName = await showDialog<String>(
context: context,
builder: (_) => QuickRenameDialog(
suggestedName: suggestedName,
previewWidget: Image.memory(item.bytes, fit: BoxFit.contain),
),
);
if (finalName == null || finalName.isEmpty) continue;
// CREAZIONE DEL PDF SINGOLO
final pdf = pw.Document();
final pdfImage = pw.MemoryImage(item.bytes); // Usiamo item.bytes!
pdf.addPage(
pw.Page(
margin: pw.EdgeInsets.zero,
build: (pw.Context context) {
return pw.Center(child: pw.Image(pdfImage));
},
),
);
final singlePdfBytes = await pdf.save();
if (kIsWeb) {
// Trigger download web
} else {
final fileToSave = File('$_exportDirectory/$finalName.pdf');
await fileToSave.writeAsBytes(singlePdfBytes);
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Esportazione completata con successo!'),
),
);
}
} catch (e) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Errore: $e')));
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// USIAMO IL TUO BLOC!
return BlocBuilder<OperationFilesBloc, OperationFilesState>(
builder: (context, state) {
final allFiles = state.allFiles;
final selectedFiles = state.selectedFiles;
final hasSelection = selectedFiles.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. SETTINGS CARTELLA (Solo visibile su Desktop)
if (!kIsWeb)
Card(
color: theme.colorScheme.surfaceContainerHighest.withValues(
alpha: 0.5,
),
elevation: 0,
margin: const EdgeInsets.only(bottom: 16),
child: ListTile(
leading: Icon(
Icons.folder_special,
color: theme.colorScheme.primary,
),
title: const Text(
'Cartella Export (Es. Citrix TIM)',
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
_exportDirectory ??
'Nessuna cartella selezionata. Clicca per impostare.',
style: TextStyle(
color: _exportDirectory == null
? theme.colorScheme.error
: null,
),
),
trailing: const Icon(Icons.settings),
onTap: _selectExportDirectory,
),
),
// 2. ACTION BAR DINAMICA
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
// Bottone di Aggiunta
ElevatedButton.icon(
icon: const Icon(Icons.add_photo_alternate),
label: const Text('Aggiungi File'),
onPressed: state.status == OperationFilesStatus.uploading
? null
: _pickFiles,
),
const SizedBox(width: 12),
// NUOVO: SELEZIONA / DESELEZIONA TUTTO
if (allFiles.isNotEmpty) ...[
TextButton.icon(
icon: Icon(
selectedFiles.length == allFiles.length
? Icons.deselect
: Icons.select_all,
),
label: Text(
selectedFiles.length == allFiles.length
? 'Deseleziona Tutto'
: 'Seleziona Tutto',
),
onPressed: () {
if (selectedFiles.length == allFiles.length) {
context.read<OperationFilesBloc>().add(
ClearOperationFileSelectionEvent(),
);
} else {
context.read<OperationFilesBloc>().add(
SelectAllOperationFilesEvent(),
);
}
},
),
],
const SizedBox(width: 12),
// Loader di upload
if (state.status == OperationFilesStatus.uploading)
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const Spacer(),
// Azioni visibili SOLO se c'è una selezione!
if (hasSelection) ...[
// Bottone Elimina
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: 'Elimina selezionati',
onPressed: () {
context.read<OperationFilesBloc>().add(
DeleteOperationFilesEvent(),
);
},
),
// Bottone Associa a Cliente
if (widget.currentOp.customerId != null &&
widget.currentOp.customerId!.isNotEmpty)
IconButton(
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!,
),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('File copiati nella scheda cliente!'),
),
);
},
),
// IL NUOVO BOTTONE ESPORTA CON MENU A TENDINA
PopupMenuButton<String>(
tooltip: 'Opzioni di esportazione',
position: PopupMenuPosition
.under, // Opzionale: fa aprire il menu sotto al bottone
onSelected: (value) {
if (value == 'merge') {
_exportMergedPdf(selectedFiles);
} else if (value == 'split') {
_exportSplitPdfs(selectedFiles);
}
},
itemBuilder: (BuildContext context) =>
<PopupMenuEntry<String>>[
const PopupMenuItem<String>(
value: 'merge',
child: ListTile(
leading: Icon(
Icons.merge_type,
color: Colors.blue,
),
title: Text('Unisci in un singolo PDF'),
),
),
const PopupMenuItem<String>(
value: 'split',
child: ListTile(
leading: Icon(
Icons.splitscreen,
color: Colors.orange,
),
title: Text(
'Dividi: un PDF per ogni pagina/foto',
),
),
),
],
// IL FIX È QUI SOTTO: AbsorbPointer + onPressed vuoto
child: AbsorbPointer(
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: Colors.red,
),
icon: const Icon(Icons.picture_as_pdf),
label: Text('Esporta (${selectedFiles.length})'),
onPressed: () {}, // Manteniamo vivo il colore!
),
),
),
],
],
),
const SizedBox(height: 16),
// 3. GRIGLIA DEI FILE
if (allFiles.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
border: Border.all(
color: theme.dividerColor,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(8),
),
child: const Column(
children: [
Icon(Icons.upload_file, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text(
'Nessun file allegato. Usa il pulsante per aggiungere documenti o foto.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 150,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: allFiles.length,
itemBuilder: (context, index) {
final file = allFiles[index];
final isPdf = file.extension == 'pdf';
final isSelected = selectedFiles.contains(file);
final isLocal =
file.localBytes !=
null; // Per capire se è un file in bozza
return Stack(
children: [
// CARD DEL FILE
InkWell(
onTap: () => _openFile(file),
onLongPress: () {
// Selezione rapida con long press!
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: isSelected ? 3 : 1,
),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Anteprima
Expanded(
child: Container(
decoration: BoxDecoration(
color: theme
.colorScheme
.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(8),
),
),
child: isPdf
? const Icon(
Icons.picture_as_pdf,
size: 48,
color: Colors.red,
)
: isLocal
? ClipRRect(
borderRadius:
const BorderRadius.vertical(
top: Radius.circular(8),
),
child: Image.memory(
file.localBytes!,
fit: BoxFit.cover,
),
)
: const Icon(
Icons.image,
size: 48,
color: Colors.blue,
), // Da remoto metterai il tuo NetworkImage se vuoi
),
),
// Nome File
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
file.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
),
),
// CHECKBOX DI SELEZIONE
Positioned(
top: 4,
right: 4,
child: InkWell(
onTap: () {
context.read<OperationFilesBloc>().add(
ToggleOperationFileSelectionEvent(file),
);
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: Colors.white.withValues(alpha: 0.8),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary,
),
),
child: Padding(
padding: const EdgeInsets.all(4.0),
child: Icon(
isSelected ? Icons.check : Icons.circle,
size: 16,
color: isSelected
? Colors.white
: Colors.transparent,
),
),
),
),
),
// BADGE "IN ATTESA" (Se è locale ma la pratica è salvata)
if (isLocal)
Positioned(
top: 4,
left: 4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'Bozza',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
],
);
},
),
],
);
},
);
}
}

View File

@@ -1,133 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart';
// IMPORTA IL TUO CUBIT DELLO STAFF
// import 'package:flux/features/staff/blocs/staff_cubit.dart';
class StaffSection extends StatelessWidget {
final OperationModel? currentOp;
const StaffSection({super.key, required this.currentOp});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final selectedStaffId =
currentOp?.staffId ??
GetIt.I.get<SessionCubit>().state.currentStaffMember?.id;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Text(
'Operatore',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
BlocBuilder<StaffCubit, StaffState>(
builder: (context, state) {
// Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder!
final staffMembers = state.storeStaff;
final currentLoggedStaffMember = GetIt.I
.get<SessionCubit>()
.state
.currentStaffMember;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: staffMembers.map((staff) {
final isSelected = staff.id == selectedStaffId;
return GestureDetector(
onTap: () {
// Aggiorniamo la form con un solo tap!
context.read<OperationsCubit>().updateOperationFields(
staffId: staff.id,
staffDisplayName: staff.name,
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(right: 12.0),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 10.0,
),
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(30),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: theme.dividerColor,
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: theme.colorScheme.primary.withValues(
alpha: 0.3,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
]
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 12,
backgroundColor: isSelected
? Colors.white
: theme.colorScheme.primaryContainer,
child: Text(
staff.name.substring(0, 1).toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 8),
Text(
staff == currentLoggedStaffMember
? 'Tu (${staff.name})'
: staff.name,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.w500,
color: isSelected
? Colors.white
: theme.colorScheme.onSurface,
),
),
],
),
),
);
}).toList(),
),
);
},
),
],
);
}
}

View File

@@ -0,0 +1,217 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:get_it/get_it.dart';
import 'ticket_form_state.dart';
class TicketFormCubit extends Cubit<TicketFormState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
final SessionCubit _sessionCubit = GetIt.I.get<SessionCubit>();
TicketFormCubit()
: super(
// Inizializziamo con un ticket vuoto di default
TicketFormState(ticket: TicketModel.empty()),
);
/// 1. INIZIALIZZAZIONE (Se stiamo modificando un ticket esistente)
Future<void> initForm({String? id, TicketModel? existingTicket}) async {
if (existingTicket != null) {
// SCENARIO 1 (App Native / Navigazione interna Web):
// Abbiamo l'oggetto intero passato via 'extra'. Lo mostriamo all'istante!
emit(
state.copyWith(ticket: existingTicket, status: TicketFormStatus.ready),
);
} else if (id != null) {
// SCENARIO 2 (Web Refresh o Link condiviso):
// L'utente ha premuto F5 su /tickets/form/123. L'extra è andato perso, ma abbiamo l'ID!
emit(
state.copyWith(status: TicketFormStatus.loading),
); // Mostriamo uno spinner
try {
final fetchedTicket = await _repository.getTicketById(
id,
); // Lo scarichiamo!
emit(
state.copyWith(ticket: fetchedTicket, status: TicketFormStatus.ready),
);
} catch (e) {
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: 'Ticket non trovato',
),
);
}
} else {
// SCENARIO 3 (Nuovo Ticket):
// È un nuovo ticket! Inseriamo i default base (Azienda, Negozio, Creatore)
final currentUser = _sessionCubit.state.currentStaffMember;
final currentStore = _sessionCubit.state.currentStore;
final companyId = _sessionCubit.state.company?.id ?? '';
final newTicket = TicketModel.empty().copyWith(
companyId: companyId,
storeId: currentStore?.id,
createdById: currentUser?.id,
createdByName: currentUser?.name,
// Impostiamo lo stato iniziale
ticketStatus: TicketStatus.open,
ticketType: TicketType.repair, // Default
);
emit(state.copyWith(ticket: newTicket, status: TicketFormStatus.ready));
}
}
/// 2. AGGIORNAMENTO CLIENTE (Usato dal nostro SharedCustomerSection!)
void updateCustomer(CustomerModel customer) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
customerId: customer.id,
customerName: customer.name,
alternativePhoneNumber: customer.phoneNumber, // Comodo come fallback!
),
),
);
}
/// 3. AGGIORNAMENTO MODELLO (Usato dal nostro SharedModelSection!)
void updateModel({required String modelId, required String modelName}) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
targetModelId: modelId,
targetModelName: modelName,
),
),
);
}
void updateCreator({required String staffId, required String staffName}) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
createdById: staffId,
createdByName: staffName,
),
),
);
}
/// 4. AGGIORNAMENTO GENERICO DEI CAMPI
void updateFields({
TicketType? ticketType,
TicketStatus? status,
String? request,
String? targetSn,
String? alternativePhoneNumber,
bool? hasCourtesyDevice,
String? includedAccessories,
String? publicNotes,
String? internalNotes,
double? customerPrice,
double? internalCost,
String? assignedToId,
String? assignedToName,
}) {
emit(
state.copyWith(
ticket: state.ticket.copyWith(
ticketType: ticketType ?? state.ticket.ticketType,
ticketStatus: status ?? state.ticket.ticketStatus,
request: request ?? state.ticket.request,
targetSn: targetSn ?? state.ticket.targetSn,
alternativePhoneNumber:
alternativePhoneNumber ?? state.ticket.alternativePhoneNumber,
hasCourtesyDevice:
hasCourtesyDevice ?? state.ticket.hasCourtesyDevice,
includedAccessories:
includedAccessories ?? state.ticket.includedAccessories,
publicNotes: publicNotes ?? state.ticket.publicNotes,
internalNotes: internalNotes ?? state.ticket.internalNotes,
customerPrice: customerPrice ?? state.ticket.customerPrice,
internalCost: internalCost ?? state.ticket.internalCost,
assignedToId: assignedToId ?? state.ticket.assignedToId,
assignedToName: assignedToName ?? state.ticket.assignedToName,
),
),
);
}
/// 5. SALVATAGGIO
Future<void> saveTicket({required bool keepAdding}) async {
emit(state.copyWith(status: TicketFormStatus.saving));
try {
final ticketToSave = state.ticket;
// Validazione base
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
throw Exception("Seleziona un cliente prima di salvare.");
}
final savedTicket = await _repository.saveTicket(ticketToSave);
if (keepAdding) {
emit(
state.copyWith(
status: TicketFormStatus.successAndAddAnother,
// Svuotiamo il form per il prossimo, mantenendo Store e Creatore ATTUALI
ticket: TicketModel.empty().copyWith(
companyId: savedTicket.companyId,
storeId: savedTicket.storeId,
createdById: ticketToSave
.createdById, // Manteniamo quello selezionato nella tendina!
createdByName: ticketToSave.createdByName,
ticketStatus: TicketStatus.open,
ticketType: TicketType.repair,
),
),
);
} else {
emit(
state.copyWith(status: TicketFormStatus.success, ticket: savedTicket),
);
}
} catch (e) {
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: e.toString(),
),
);
}
}
/// 5.1 SALVATAGGIO SILENZIOSO (Per generare il QR Code al volo)
Future<String?> saveTicketDraft() async {
// Non mettiamo lo stato 'saving' per non far sfarfallare tutta la UI,
// usiamo un caricamento invisibile.
try {
final ticketToSave = state.ticket;
if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) {
throw Exception("Seleziona un cliente prima di poter usare il QR.");
}
final savedTicket = await _repository.saveTicket(ticketToSave);
// Aggiorniamo silenziosamente lo stato con il ticket che ora ha un ID!
emit(state.copyWith(ticket: savedTicket, status: TicketFormStatus.ready));
return savedTicket.id;
} catch (e) {
emit(
state.copyWith(
status: TicketFormStatus.failure,
errorMessage: e.toString(),
),
);
return null;
}
}
}

View File

@@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
// Adatta gli import al tuo progetto!
enum TicketFormStatus {
initial,
ready,
loading,
saving,
success,
successAndAddAnother,
failure,
}
class TicketFormState extends Equatable {
final TicketModel ticket;
final TicketFormStatus status;
final String? errorMessage;
const TicketFormState({
required this.ticket,
this.status = TicketFormStatus.initial,
this.errorMessage,
});
@override
List<Object?> get props => [ticket, status, errorMessage];
TicketFormState copyWith({
TicketModel? ticket,
TicketFormStatus? status,
String? errorMessage,
}) {
return TicketFormState(
ticket: ticket ?? this.ticket,
status: status ?? this.status,
errorMessage: errorMessage,
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/data/ticket_repository.dart';
import 'package:get_it/get_it.dart';
import 'ticket_list_state.dart';
class TicketListCubit extends Cubit<TicketListState> {
final TicketRepository _repository = GetIt.I.get<TicketRepository>();
static const int _limit = 20; // Paginazione a blocchi di 20
TicketListCubit() : super(const TicketListState()) {
fetchTickets(reset: true);
}
/// Recupera i ticket. Se reset = true, svuota la lista e riparte da offset 0.
Future<void> fetchTickets({bool reset = false}) async {
if (state.isLoading) return;
if (!reset && state.hasReachedMax) return;
emit(
state.copyWith(
isLoading: true,
errorMessage: '',
tickets: reset ? [] : state.tickets,
),
);
try {
final currentOffset = reset ? 0 : state.tickets.length;
final newTickets = await _repository.fetchStoreTickets(
offset: currentOffset,
limit: _limit,
searchTerm: state.searchTerm,
dateRange: state.dateRange,
statusFilter: state.statusFilter,
ticketTypeFilter: state.ticketTypeFilter,
staffIdFilter: state.staffIdFilter,
);
emit(
state.copyWith(
tickets: reset ? newTickets : [...state.tickets, ...newTickets],
isLoading: false,
hasReachedMax: newTickets.length < _limit,
),
);
} catch (e) {
emit(state.copyWith(isLoading: false, errorMessage: e.toString()));
}
}
/// Aggiorna i filtri e ricarica tutto da zero
void updateFilters({
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? statusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
bool clearSearch = false,
bool clearDate = false,
bool clearStatus = false,
}) {
emit(
state.copyWith(
searchTerm: searchTerm,
dateRange: dateRange,
statusFilter: statusFilter,
ticketTypeFilter: ticketTypeFilter,
staffIdFilter: staffIdFilter,
clearSearch: clearSearch,
clearDate: clearDate,
clearStatus: clearStatus,
),
);
fetchTickets(reset: true); // Applica i filtri e ricarica
}
}

View File

@@ -0,0 +1,69 @@
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
class TicketListState extends Equatable {
final List<TicketModel> tickets;
final bool isLoading;
final bool hasReachedMax;
final String errorMessage;
// Filtri attivi
final String? searchTerm;
final DateTimeRange? dateRange;
final TicketStatus? statusFilter;
final TicketType? ticketTypeFilter;
final String? staffIdFilter;
const TicketListState({
this.tickets = const [],
this.isLoading = false,
this.hasReachedMax = false,
this.errorMessage = '',
this.searchTerm,
this.dateRange,
this.statusFilter,
this.ticketTypeFilter,
this.staffIdFilter,
});
TicketListState copyWith({
List<TicketModel>? tickets,
bool? isLoading,
bool? hasReachedMax,
String? errorMessage,
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? statusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
bool clearSearch = false,
bool clearDate = false,
bool clearStatus = false,
}) {
return TicketListState(
tickets: tickets ?? this.tickets,
isLoading: isLoading ?? this.isLoading,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
errorMessage: errorMessage ?? this.errorMessage,
searchTerm: clearSearch ? null : (searchTerm ?? this.searchTerm),
dateRange: clearDate ? null : (dateRange ?? this.dateRange),
statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter),
ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter,
staffIdFilter: staffIdFilter ?? this.staffIdFilter,
);
}
@override
List<Object?> get props => [
tickets,
isLoading,
hasReachedMax,
errorMessage,
searchTerm,
dateRange,
statusFilter,
ticketTypeFilter,
staffIdFilter,
];
}

View File

@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
class TicketRepository {
final SupabaseClient _supabase = GetIt.I.get<SupabaseClient>();
TicketRepository();
static const String _tableName = 'ticket';
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI UNO STORE ---
Future<List<TicketModel>> fetchStoreTickets({
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? statusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
}) async {
try {
var query = _supabase
.from(_tableName)
.select('''
*,
customer (*),
created_by:staff_member!ticket_staff_id_fkey (*),
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
target_model:model!ticket_model_id_1_fkey (*),
source_model:model!ticket_model_id_2_fkey (*)
''')
.eq('store_id', GetIt.I.get<SessionCubit>().state.currentStore!.id!);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (statusFilter != null) {
query = query.eq('status', statusFilter.value);
}
if (ticketTypeFilter != null) {
query = query.eq('ticket_type', ticketTypeFilter.value);
}
if (staffIdFilter != null) {
query = query.eq('staff_id', staffIdFilter);
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or('customer.name.ilike.%$searchTerm%');
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List).map((map) => TicketModel.fromMap(map)).toList();
} catch (e) {
throw Exception('$e');
}
}
// --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI TUTTA L'AZIENDA ---
Future<List<TicketModel>> fetchCompanyTickets({
required int offset,
int limit = 50,
String? searchTerm,
DateTimeRange? dateRange,
TicketStatus? ticketStatusFilter,
TicketType? ticketTypeFilter,
String? staffIdFilter,
}) async {
try {
var query = _supabase
.from(_tableName)
.select('''
*,
customer (*),
created_by:staff_member!ticket_staff_id_fkey (*),
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
target_model:model!ticket_model_id_1_fkey (*),
source_model:model!ticket_model_id_2_fkey (*)
''')
.eq('company_id', GetIt.I.get<SessionCubit>().state.company!.id!);
// Filtro Range Date
if (dateRange != null) {
query = query
.gte('created_at', dateRange.start.toIso8601String())
.lte('created_at', dateRange.end.toIso8601String());
}
if (ticketStatusFilter != null) {
query = query.eq('status', ticketStatusFilter.value);
}
if (ticketTypeFilter != null) {
query = query.eq('ticket_type', ticketTypeFilter.value);
}
if (staffIdFilter != null) {
query = query.eq('staff_id', staffIdFilter);
}
if (searchTerm != null && searchTerm.isNotEmpty) {
// Filtra sui campi della tabella principale O su quelli della tabella joinata
query = query.or('customer.name.ilike.%$searchTerm%');
}
final response = await query
.order('created_at', ascending: false)
.range(offset, offset + limit - 1);
return (response as List).map((map) => TicketModel.fromMap(map)).toList();
} catch (e) {
throw Exception('$e');
}
}
/// Stream dei ticket che necessitano attenzione (es. in scadenza oggi o in ritardo)
Stream<List<TicketModel>> getAttentionNeededTicketsStream() {
return _supabase
.from(_tableName)
.stream(primaryKey: ['id'])
.eq('store_id', GetIt.I.get<SessionCubit>().state.currentStore!.id!)
// Purtroppo lo stream accetta solo filtri base, quindi ci facciamo
// mandare i dati e li filtriamo con la potenza di Dart!
.limit(300)
.map((listOfMaps) {
final now = DateTime.now();
final endOfToday = DateTime(now.year, now.month, now.day, 23, 59, 59);
// 1. Mappiamo tutto in TicketModel
final allStoreTickets = listOfMaps
.map((map) => TicketModel.fromMap(map))
.toList();
// 2. Filtriamo in memoria!
final urgentTickets = allStoreTickets.where((ticket) {
// Escludiamo quelli già chiusi o consegnati
if (ticket.ticketStatus == TicketStatus.closed ||
ticket.ticketStatus == TicketStatus.ready) {
return false;
}
// Se c'è una data di consegna stimata ed è <= a stasera, è urgente!
if (ticket.estimatedDeliveryAt != null) {
return ticket.estimatedDeliveryAt!.isBefore(endOfToday);
}
return false;
}).toList();
// 3. Li ordiniamo mettendo i più vecchi/urgenti in cima
urgentTickets.sort(
(a, b) => a.estimatedDeliveryAt!.compareTo(b.estimatedDeliveryAt!),
);
return urgentTickets;
});
}
/// Recupera un ticket specifico CON TUTTE LE RELAZIONI espanse (Cliente e Modelli)
/// Questa è la vera magia di Supabase!
Future<TicketModel> getTicketById(String ticketId) async {
try {
// Usiamo i nomi esatti delle Foreign Key che hai definito nell'SQL!
final response = await _supabase
.from(_tableName)
.select('''
*,
customer (*),
target_model:model!ticket_model_id_1_fkey (*),
source_model:model!ticket_model_id_2_fkey (*),
created_by:staff_member!ticket_staff_id_fkey (*),
assigned_to:staff_member!ticket_assigned_to_id_fkey (*),
''')
.eq('id', ticketId)
.single();
return TicketModel.fromMap(response);
} catch (e) {
throw Exception('Errore nel recupero del dettaglio ticket: $e');
}
}
/// Salva il ticket con upsert
Future<TicketModel> saveTicket(TicketModel ticket) async {
try {
final response = await _supabase
.from(_tableName)
.upsert(ticket.toMap())
.select()
.single();
return TicketModel.fromMap(response);
} catch (e) {
throw Exception('Errore nella creazione del ticket: $e');
}
}
/// Aggiorna un ticket esistente
Future<TicketModel> updateTicket(TicketModel ticket) async {
if (ticket.id == null) {
throw Exception('Impossibile aggiornare un ticket senza ID');
}
try {
final response = await _supabase
.from(_tableName)
.update(ticket.toMap())
.eq('id', ticket.id!)
.select()
.single();
return TicketModel.fromMap(response);
} catch (e) {
throw Exception('Errore nell\'aggiornamento del ticket: $e');
}
}
/// Elimina (o annulla) un ticket
Future<void> deleteTicket(String ticketId) async {
try {
await _supabase.from(_tableName).delete().eq('id', ticketId);
} catch (e) {
throw Exception('Errore nell\'eliminazione del ticket: $e');
}
}
}

View File

@@ -0,0 +1,367 @@
import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/extensions.dart';
/// Enum per il tipo di ticket
enum TicketType {
repair('repair', 'Riparazione'),
softwareSetup('software_setup', 'Setup software'),
dataTransfer('data_transfer', 'Trasferimento dati'),
operationTicket('operation_ticket', 'Ticket di operazione'),
other('other', 'Altro');
final String value;
final String displayValue;
const TicketType(this.value, this.displayValue);
static TicketType fromString(String val) {
return TicketType.values.firstWhere(
(e) => e.value == val,
orElse: () => TicketType.other,
);
}
}
/// Enum per lo stato del ticket
enum TicketStatus {
open('open', 'Aperto'),
inProgress('in_progress', 'In corso'),
waitingForParts('waiting_for_parts', 'In attesa di ricambi'),
ready('ready', 'Pronto'),
closed('closed', 'Chiuso'),
waitingForShipping('waiting_for_shipping', 'In attesa di spedire'),
waitingForReturn('waiting_for_return', 'In attesa di ritorno');
final String value;
final String displayValue;
const TicketStatus(this.value, this.displayValue);
static TicketStatus fromString(String? val) {
return TicketStatus.values.firstWhere(
(e) => e.value == val,
orElse: () => TicketStatus.open,
);
}
}
/// Enum per il risultato del ticket (OK / KO)
enum TicketResult {
success('success', 'Risolto (OK)'),
failure('failure', 'Non Risolto (KO)');
final String value;
final String displayValue;
const TicketResult(this.value, this.displayValue);
static TicketResult? fromString(String? val) {
if (val == null) return null;
return TicketResult.values.firstWhere(
(e) => e.value == val,
orElse: () => TicketResult.success,
);
}
}
/// Enum per il tipo di garanzia
enum WarrantyType {
manufacturerWarranty('manufacturer_warranty', 'Garanzia produttore'),
providerWarranty('provider_warranty', 'Garanzia gestore'),
internalWarranty('internal_warranty', 'Garanzia interna'),
noWarranty('no_warranty', 'Fuori garanzia');
final String value;
final String displayValue;
const WarrantyType(this.value, this.displayValue);
static WarrantyType? fromString(String? val) {
return WarrantyType.values.firstWhere(
(e) => e.value == val,
orElse: () => WarrantyType.noWarranty,
);
}
}
class TicketModel extends Equatable {
final String? id; // Null se non ancora salvato
final DateTime? createdAt;
final String companyId;
final String? storeId;
final String? customerId;
final String? targetModelId;
final String? targetSn;
final String? sourceModelId;
final String? sourceSn;
final double customerPrice;
final double internalCost;
final DateTime? closedAt;
final DateTime? returnedAt;
final String request;
final WarrantyType? warrantyType;
final String? publicNotes;
final String? internalNotes;
final int? referenceNumber;
final String? alternativePhoneNumber;
final bool hasCourtesyDevice;
final TicketType ticketType;
final TicketStatus ticketStatus;
final DateTime? estimatedDeliveryAt;
final TicketResult? ticketResult;
final String? resolutionNotes;
final String? legacyId;
final String? customerName;
final String? targetModelName;
final String? sourceModelName;
final String? createdById;
final String? createdByName;
final String? assignedToId;
final String? assignedToName;
final String? includedAccessories;
const TicketModel({
this.id,
this.createdAt,
required this.companyId,
this.storeId,
this.customerId,
this.targetModelId,
this.targetSn,
this.sourceModelId,
this.sourceSn,
this.customerPrice = 0.0,
this.internalCost = 0.0,
this.closedAt,
this.returnedAt,
this.request = '',
this.warrantyType,
this.publicNotes,
this.internalNotes,
this.referenceNumber,
this.alternativePhoneNumber,
this.hasCourtesyDevice = false,
required this.ticketType,
this.ticketStatus = TicketStatus.closed,
this.estimatedDeliveryAt,
this.ticketResult,
this.resolutionNotes,
this.legacyId,
this.customerName,
this.targetModelName,
this.sourceModelName,
this.createdById,
this.createdByName,
this.assignedToId,
this.assignedToName,
this.includedAccessories,
});
/// Factory per creare un ticket vuoto (utile per i form di creazione)
factory TicketModel.empty({String? companyId, String? storeId}) {
return TicketModel(
companyId: companyId ?? '',
storeId: storeId,
ticketType: TicketType.repair, // Valore di default
ticketStatus: TicketStatus.open,
customerPrice: 0.0,
internalCost: 0.0,
hasCourtesyDevice: false,
request: '',
);
}
TicketModel copyWith({
String? id,
DateTime? createdAt,
String? companyId,
String? storeId,
String? customerId,
String? targetModelId,
String? targetSn,
String? sourceModelId,
String? sourceSn,
double? customerPrice,
double? internalCost,
DateTime? closedAt,
DateTime? returnedAt,
String? request,
WarrantyType? warrantyType,
String? publicNotes,
String? internalNotes,
int? referenceNumber,
String? alternativePhoneNumber,
bool? hasCourtesyDevice,
TicketType? ticketType,
TicketStatus? ticketStatus,
DateTime? estimatedDeliveryAt,
TicketResult? ticketResult,
String? resolutionNotes,
String? legacyId,
String? customerName,
String? targetModelName,
String? sourceModelName,
String? createdById,
String? createdByName,
String? assignedToId,
String? assignedToName,
String? includedAccessories,
}) {
return TicketModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
companyId: companyId ?? this.companyId,
storeId: storeId ?? this.storeId,
customerId: customerId ?? this.customerId,
targetModelId: targetModelId ?? this.targetModelId,
targetSn: targetSn ?? this.targetSn,
sourceModelId: sourceModelId ?? this.sourceModelId,
sourceSn: sourceSn ?? this.sourceSn,
customerPrice: customerPrice ?? this.customerPrice,
internalCost: internalCost ?? this.internalCost,
closedAt: closedAt ?? this.closedAt,
returnedAt: returnedAt ?? this.returnedAt,
request: request ?? this.request,
warrantyType: warrantyType ?? this.warrantyType,
publicNotes: publicNotes ?? this.publicNotes,
internalNotes: internalNotes ?? this.internalNotes,
referenceNumber: referenceNumber ?? this.referenceNumber,
alternativePhoneNumber:
alternativePhoneNumber ?? this.alternativePhoneNumber,
hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice,
ticketType: ticketType ?? this.ticketType,
ticketStatus: ticketStatus ?? this.ticketStatus,
estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt,
ticketResult: ticketResult ?? this.ticketResult,
resolutionNotes: resolutionNotes ?? this.resolutionNotes,
legacyId: legacyId ?? this.legacyId,
customerName: customerName ?? this.customerName,
targetModelName: targetModelName ?? this.targetModelName,
sourceModelName: sourceModelName ?? this.sourceModelName,
createdById: createdById ?? this.createdById,
createdByName: createdByName ?? this.createdByName,
assignedToId: assignedToId ?? this.assignedToId,
assignedToName: assignedToName ?? this.assignedToName,
includedAccessories: includedAccessories ?? this.includedAccessories,
);
}
/// Deserializzazione da Supabase
factory TicketModel.fromMap(Map<String, dynamic> map) {
return TicketModel(
id: map['id'] as String,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at']).toLocal()
: null,
companyId: map['company_id'] as String,
storeId: map['store_id'] as String?,
customerId: map['customer_id'] as String?,
targetModelId: map['target_model_id'] as String?,
targetSn: map['target_sn'] as String?,
sourceModelId: map['source_model_id'] as String?,
sourceSn: map['source_sn'] as String?,
// Fix per i field numerici di Postgres che potrebbero arrivare come int o double
customerPrice: (map['customer_price'] as num?)?.toDouble() ?? 0.0,
internalCost: (map['internal_cost'] as num?)?.toDouble() ?? 0.0,
closedAt: map['closed_at'] != null
? DateTime.parse(map['closed_at']).toLocal()
: null,
returnedAt: map['returned_at'] != null
? DateTime.parse(map['returned_at']).toLocal()
: null,
request: map['request'] as String? ?? '',
warrantyType: WarrantyType.fromString(map['warranty_type'] as String?),
publicNotes: map['public_notes'] as String?,
internalNotes: map['internal_notes'] as String?,
referenceNumber: map['reference_number'] as int?,
alternativePhoneNumber: map['alternative_phone_number'] as String?,
hasCourtesyDevice: map['has_courtesy_device'] as bool? ?? false,
ticketType: TicketType.fromString(map['ticket_type'] as String),
ticketStatus: TicketStatus.fromString(map['ticket_status'] as String),
estimatedDeliveryAt: map['estimated_delivery_at'] != null
? DateTime.parse(map['estimated_delivery_at']).toLocal()
: null,
ticketResult: TicketResult.fromString(map['ticket_result'] as String?),
resolutionNotes: map['resolution_notes'] as String?,
legacyId: map['legacy_id'] as String?,
customerName: (map['customer']?['name'] as String?).myFormat(),
targetModelName: (map['target_model']?['name_with_brand'] as String?)
?.myFormat(),
sourceModelName: (map['source_model']?['name_with_brand'] as String?)
?.myFormat(),
createdById: map['staff_id'] as String?,
createdByName: (map['staff']?['name'] as String?).myFormat(),
assignedToId: map['assigned_to_id'] as String?,
assignedToName: (map['assigned_to']?['name'] as String?).myFormat(),
includedAccessories: map['included_accessories'] as String?,
);
}
/// Serializzazione per Supabase
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'company_id': companyId,
'store_id': storeId,
'customer_id': customerId,
'target_model_id': targetModelId,
'target_sn': targetSn,
'source_model_id': sourceModelId,
'source_sn': sourceSn,
'customer_price': customerPrice,
'internal_cost': internalCost,
if (closedAt != null) 'closed_at': closedAt!.toUtc().toIso8601String(),
if (returnedAt != null)
'returned_at': returnedAt!.toUtc().toIso8601String(),
'request': request,
'created_by_id': createdById,
'warranty_type': warrantyType,
'public_notes': publicNotes,
'internal_notes': internalNotes,
'alternative_phone_number': alternativePhoneNumber,
'has_courtesy_device': hasCourtesyDevice,
'ticket_type': ticketType.value,
'ticket_status': ticketStatus.value,
if (estimatedDeliveryAt != null)
'estimated_delivery_at': estimatedDeliveryAt!.toUtc().toIso8601String(),
if (ticketResult != null) 'ticket_result': ticketResult!.value,
'resolution_notes': resolutionNotes,
'legacy_id': legacyId,
'included_accessories': includedAccessories,
};
}
@override
List<Object?> get props => [
id,
createdAt,
companyId,
storeId,
customerId,
targetModelId,
targetSn,
sourceModelId,
sourceSn,
customerPrice,
internalCost,
closedAt,
returnedAt,
request,
warrantyType,
publicNotes,
internalNotes,
referenceNumber,
alternativePhoneNumber,
hasCourtesyDevice,
ticketType,
ticketStatus,
estimatedDeliveryAt,
ticketResult,
resolutionNotes,
legacyId,
includedAccessories,
customerName,
targetModelName,
sourceModelName,
createdById,
createdByName,
assignedToId,
assignedToName,
];
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
extension TicketStatusVisuals on TicketStatus {
Color get color {
switch (this) {
case TicketStatus.open:
return Colors.blueGrey;
case TicketStatus.waitingForParts:
return Colors.amber.shade700;
case TicketStatus.inProgress:
return Colors.blue;
case TicketStatus.waitingForShipping:
// Il tuo rosa storico!
return Colors.pinkAccent;
case TicketStatus.waitingForReturn:
return Colors.purpleAccent;
case TicketStatus.ready:
return Colors.green;
case TicketStatus.closed:
return Colors.grey.shade400;
}
}
IconData get icon {
switch (this) {
case TicketStatus.open:
return Icons.inbox;
case TicketStatus.waitingForParts:
return Icons.hourglass_empty;
case TicketStatus.inProgress:
return Icons.build;
case TicketStatus.waitingForShipping:
return Icons.local_shipping_outlined;
case TicketStatus.waitingForReturn:
return Icons.undo;
case TicketStatus.ready:
return Icons.check_circle_outline;
case TicketStatus.closed:
return Icons.lock_outline;
}
}
}

View File

@@ -0,0 +1,597 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/widgets/shared_forms/customer_section.dart';
import 'package:flux/core/widgets/shared_forms/model_section.dart';
import 'package:flux/core/widgets/shared_forms/shared_files_section.dart';
import 'package:flux/features/attachments/blocs/attachments_bloc.dart';
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
import 'package:flux/features/tickets/blocs/ticket_form_state.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/core/widgets/shared_forms/staff_section.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
class TicketFormScreen extends StatefulWidget {
final TicketModel? existingTicket;
final String? ticketId;
const TicketFormScreen({super.key, this.existingTicket, this.ticketId});
@override
State<TicketFormScreen> createState() => _TicketFormScreenState();
}
class _TicketFormScreenState extends State<TicketFormScreen> {
final _formKey = GlobalKey<FormState>();
final _altPhoneCtrl = TextEditingController();
final _serialCtrl = TextEditingController();
final _requestCtrl = TextEditingController();
final _accessoriesCtrl = TextEditingController();
final _publicNotesCtrl = TextEditingController();
final _internalNotesCtrl = TextEditingController();
final _priceCtrl = TextEditingController();
final _costCtrl = TextEditingController();
bool _isInitialized = false;
@override
void initState() {
super.initState();
context.read<TicketFormCubit>().initForm(
existingTicket: widget.existingTicket,
id: widget.ticketId,
);
}
@override
void dispose() {
_altPhoneCtrl.dispose();
_serialCtrl.dispose();
_requestCtrl.dispose();
_accessoriesCtrl.dispose();
_publicNotesCtrl.dispose();
_internalNotesCtrl.dispose();
_priceCtrl.dispose();
_costCtrl.dispose();
super.dispose();
}
void _syncTextControllers(TicketModel model) {
if (_altPhoneCtrl.text.isEmpty) {
_altPhoneCtrl.text = model.alternativePhoneNumber ?? '';
}
if (_serialCtrl.text.isEmpty) _serialCtrl.text = model.targetSn ?? '';
if (_requestCtrl.text.isEmpty) _requestCtrl.text = model.request;
if (_accessoriesCtrl.text.isEmpty) {
_accessoriesCtrl.text = model.includedAccessories ?? '';
}
if (_publicNotesCtrl.text.isEmpty) {
_publicNotesCtrl.text = model.publicNotes ?? '';
}
if (_internalNotesCtrl.text.isEmpty) {
_internalNotesCtrl.text = model.internalNotes ?? '';
}
if (_priceCtrl.text.isEmpty && model.customerPrice > 0) {
_priceCtrl.text = model.customerPrice.toString();
}
if (_costCtrl.text.isEmpty && model.internalCost > 0) {
_costCtrl.text = model.internalCost.toString();
}
_isInitialized = true;
}
void _flushControllersToCubit() {
context.read<TicketFormCubit>().updateFields(
alternativePhoneNumber: _altPhoneCtrl.text,
targetSn: _serialCtrl.text,
request: _requestCtrl.text,
includedAccessories: _accessoriesCtrl.text,
publicNotes: _publicNotesCtrl.text,
internalNotes: _internalNotesCtrl.text,
customerPrice: double.tryParse(_priceCtrl.text) ?? 0.0,
internalCost: double.tryParse(_costCtrl.text) ?? 0.0,
);
}
void _saveTicket({required bool keepAdding}) {
if (_formKey.currentState!.validate()) {
_flushControllersToCubit();
context.read<TicketFormCubit>().saveTicket(keepAdding: keepAdding);
}
}
Future<String?> _generateIdForQr() async {
// 1. Validiamo i campi obbligatori (es. il cliente)
if (!_formKey.currentState!.validate()) return null;
// 2. Sincronizziamo i testi scritti a mano nel Cubit
_flushControllersToCubit();
// 3. Facciamo il salvataggio silenzioso
final newId = await context.read<TicketFormCubit>().saveTicketDraft();
if (newId != null && context.mounted) {
// 4. IL TOCCO DI CLASSE: Diciamo all'AttachmentsBloc che ora la pratica ha un ID!
// Questo farà partire l'upload automatico di eventuali file "in bozza"
context.read<AttachmentsBloc>().add(ParentEntitySavedEvent(newId));
}
return newId;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return BlocConsumer<TicketFormCubit, TicketFormState>(
listenWhen: (previous, current) => previous.status != current.status,
listener: (context, state) {
if (state.status == TicketFormStatus.ready && !_isInitialized) {
_syncTextControllers(state.ticket);
}
if (state.status == TicketFormStatus.success) {
Navigator.of(context).pop();
} else if (state.status == TicketFormStatus.successAndAddAnother) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Scheda salvata! Inserisci la prossima.'),
),
);
_altPhoneCtrl.clear();
_serialCtrl.clear();
_requestCtrl.clear();
_accessoriesCtrl.clear();
_publicNotesCtrl.clear();
_internalNotesCtrl.clear();
_priceCtrl.clear();
_costCtrl.clear();
_isInitialized = false;
} else if (state.status == TicketFormStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
backgroundColor: theme.colorScheme.error,
),
);
}
},
builder: (context, state) {
final ticket = state.ticket;
return Scaffold(
appBar: AppBar(
title: Text(
ticket.id == null ? 'Nuova Scheda Assistenza' : 'Modifica Scheda',
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Chip(
label: Text(
ticket.ticketStatus.name.toUpperCase(),
style: const TextStyle(color: Colors.white, fontSize: 10),
),
backgroundColor: ticket.ticketStatus.color,
),
),
],
),
body: Form(
key: _formKey,
// IL TRUCCO PER LA TASTIERA: Obblighiamo il tab a seguire il DOM
child: FocusTraversalGroup(
policy: WidgetOrderTraversalPolicy(),
child: LayoutBuilder(
builder: (context, constraints) {
final isUltraWide = constraints.maxWidth > 1400;
final isDesktop = constraints.maxWidth > 900;
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: isUltraWide
? 1600
: (isDesktop ? 1200 : 800),
),
child: _buildResponsiveLayout(
isUltraWide,
isDesktop,
ticket,
),
),
),
);
},
),
),
),
bottomNavigationBar: SafeArea(
child: Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -3),
),
],
),
child: FocusTraversalGroup(
// Un gruppo a parte per il footer, così viene visitato per ultimo
child: Row(
children: [
Expanded(
flex: 1,
child: OutlinedButton(
onPressed: state.status == TicketFormStatus.saving
? null
: () => _saveTicket(keepAdding: true),
child: const Text(
'Salva e Aggiungi Altro',
textAlign: TextAlign.center,
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: ElevatedButton(
onPressed: state.status == TicketFormStatus.saving
? null
: () => _saveTicket(keepAdding: false),
child: state.status == TicketFormStatus.saving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
)
: const Text('Salva ed Esci'),
),
),
],
),
),
),
),
);
},
);
}
// --- LOGICA DI IMPAGINAZIONE RESPONSIVE ---
Widget _buildResponsiveLayout(
bool isUltraWide,
bool isDesktop,
TicketModel ticket,
) {
if (isUltraWide) {
// 3 COLONNE
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [_cardAnagrafica(ticket), _cardDispositivo(ticket)],
),
),
const SizedBox(width: 24),
Expanded(child: Column(children: [_cardDettagli(ticket)])),
const SizedBox(width: 24),
Expanded(
child: Column(
children: [_cardCosti(ticket), _cardAssegnazione(ticket)],
),
),
],
);
} else if (isDesktop) {
// 2 COLONNE
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
children: [
_cardAnagrafica(ticket),
_cardDispositivo(ticket),
_cardAssegnazione(ticket),
],
),
),
const SizedBox(width: 24),
Expanded(
child: Column(
children: [_cardDettagli(ticket), _cardCosti(ticket)],
),
),
],
);
} else {
// 1 COLONNA (Mobile)
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_cardAnagrafica(ticket),
_cardDispositivo(ticket),
_cardDettagli(ticket),
_cardCosti(ticket),
_cardAssegnazione(ticket),
],
);
}
}
// --- LE 5 CARD (MODULARIZZATE E COLORATE) ---
Widget _cardAnagrafica(TicketModel ticket) {
return _buildCard(
title: 'Anagrafica',
icon: Icons.person,
themeColor: Colors.indigo,
children: [
StaffSection(
label: 'Creato Da',
staffId: ticket.createdById,
staffName: ticket.createdByName,
onStaffSelected: (staff) => context
.read<TicketFormCubit>()
.updateCreator(staffId: staff.id!, staffName: staff.name),
),
const Divider(height: 32),
SharedCustomerSection(
customerId: ticket.customerId,
customerName: ticket.customerName,
onCustomerSelected: (customer) =>
context.read<TicketFormCubit>().updateCustomer(customer),
),
const SizedBox(height: 16),
TextFormField(
controller: _altPhoneCtrl,
decoration: const InputDecoration(
labelText: 'Recapito Alternativo',
prefixIcon: Icon(Icons.phone),
),
),
],
);
}
Widget _cardDispositivo(TicketModel ticket) {
return _buildCard(
title: 'Dispositivo',
icon: Icons.devices,
themeColor: Colors.deepOrange,
children: [
SharedModelSection(
label: 'Modello da Riparare',
modelId: ticket.targetModelId,
modelName: ticket.targetModelName,
onModelSelected: (id, name) => context
.read<TicketFormCubit>()
.updateModel(modelId: id, modelName: name),
),
const SizedBox(height: 16),
TextFormField(
controller: _serialCtrl,
decoration: const InputDecoration(
labelText: 'Seriale / IMEI',
prefixIcon: Icon(Icons.qr_code),
),
),
],
);
}
Widget _cardDettagli(TicketModel ticket) {
return _buildCard(
title: 'Dettagli Riparazione',
icon: Icons.build,
themeColor: Colors.pink,
children: [
Row(
children: [
Expanded(
child: DropdownButtonFormField<TicketType>(
initialValue: ticket.ticketType,
decoration: const InputDecoration(
labelText: 'Tipo Lavorazione',
),
items: TicketType.values
.map((t) => DropdownMenuItem(value: t, child: Text(t.name)))
.toList(),
onChanged: (val) => context
.read<TicketFormCubit>()
.updateFields(ticketType: val),
),
),
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<TicketStatus>(
initialValue: ticket.ticketStatus,
decoration: const InputDecoration(labelText: 'Stato Attuale'),
items: TicketStatus.values
.map((s) => DropdownMenuItem(value: s, child: Text(s.name)))
.toList(),
onChanged: (val) =>
context.read<TicketFormCubit>().updateFields(status: val),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _requestCtrl,
maxLines: 4,
decoration: const InputDecoration(
labelText: 'Difetto dichiarato o Richiesta del cliente',
alignLabelWithHint: true,
),
),
const SizedBox(height: 16),
TextFormField(
controller: _accessoriesCtrl,
decoration: const InputDecoration(
labelText: 'Accessori Consegnati',
prefixIcon: Icon(Icons.cable),
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Prestato Telefono di Cortesia?'),
value: ticket.hasCourtesyDevice,
onChanged: (val) => context.read<TicketFormCubit>().updateFields(
hasCourtesyDevice: val,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: Theme.of(context).dividerColor),
),
),
],
);
}
Widget _cardCosti(TicketModel ticket) {
return _buildCard(
title: 'Costi & Note',
icon: Icons.euro,
themeColor: Colors.teal,
children: [
Row(
children: [
Expanded(
child: TextFormField(
controller: _priceCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Preventivo Cliente (€)',
prefixIcon: Icon(Icons.sell_outlined),
),
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _costCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Nostro Costo (€)',
prefixIcon: Icon(Icons.shopping_cart_outlined),
),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _publicNotesCtrl,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Note Pubbliche (Visibili su ricevuta)',
),
),
const SizedBox(height: 16),
TextFormField(
controller: _internalNotesCtrl,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Note Interne (Solo per lo Staff)',
fillColor: Colors.amber.withValues(alpha: 0.1),
filled: true,
),
),
],
);
}
Widget _cardAssegnazione(TicketModel ticket) {
return _buildCard(
title: 'Assegnazione e Allegati',
icon: Icons.engineering,
themeColor: Colors.deepPurple,
children: [
StaffSection(
label: 'Assegnato A',
staffId: ticket.assignedToId,
staffName: ticket.assignedToName,
onStaffSelected: (staff) => context
.read<TicketFormCubit>()
.updateFields(assignedToId: staff.id, assignedToName: staff.name),
),
const Divider(height: 32),
// ECCO LA MAGIA:
SharedFilesSection(
titleNameForUpload: ticket.customerName ?? 'Nuovo Ticket',
onGenerateIdForQr: _generateIdForQr,
),
/* SharedAttachmentsSection(
parentType: AttachmentParentType.ticket,
parentId: ticket.id,
), */
],
);
}
// --- WIDGET BASE PER LA CARD ---
Widget _buildCard({
required String title,
required IconData icon,
required Color themeColor,
required List<Widget> children,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 24),
elevation: 0, // Tolta l'ombra standard
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: themeColor.withValues(alpha: 0.3),
width: 1,
), // Bordo colorato delicato
),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Pallino colorato con l'icona dentro
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: themeColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: themeColor),
),
const SizedBox(width: 12),
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: themeColor,
),
),
],
),
const Divider(height: 32),
...children,
],
),
),
);
}
}

View File

@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart';
import 'package:flux/features/tickets/blocs/ticket_list_state.dart';
import 'package:flux/features/tickets/models/ticket_model.dart';
import 'package:flux/features/tickets/models/ticket_status_extension.dart';
class TicketListScreen extends StatefulWidget {
const TicketListScreen({super.key});
@override
State<TicketListScreen> createState() => _TicketListScreenState();
}
class _TicketListScreenState extends State<TicketListScreen> {
final ScrollController _scrollController = ScrollController();
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
// INFINITY SCROLL: Quando arriviamo quasi in fondo, chiediamo altri ticket
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
context.read<TicketListCubit>().fetchTickets();
}
});
}
@override
void dispose() {
_scrollController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Assistenza & Riparazioni'),
actions: [
// Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet!
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () {
// TODO: Aprire BottomSheet filtri avanzati
},
),
],
),
body: Column(
children: [
// 1. BARRA DI RICERCA
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Cerca per nome cliente...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
context.read<TicketListCubit>().updateFilters(
clearSearch: true,
);
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onSubmitted: (value) {
context.read<TicketListCubit>().updateFilters(
searchTerm: value,
);
},
),
),
// 2. FILTRI RAPIDI PER STATO (CHIPS)
BlocBuilder<TicketListCubit, TicketListState>(
buildWhen: (previous, current) =>
previous.statusFilter != current.statusFilter,
builder: (context, state) {
return SizedBox(
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8.0),
children: [
_buildStatusChip(context, state, null, 'Tutti'),
...TicketStatus.values.map(
(status) => _buildStatusChip(
context,
state,
status,
status.displayValue,
),
),
],
),
);
},
),
const Divider(),
// 3. LA LISTA DEI TICKET
Expanded(
child: BlocBuilder<TicketListCubit, TicketListState>(
builder: (context, state) {
if (state.isLoading && state.tickets.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (state.tickets.isEmpty) {
return const Center(child: Text('Nessun ticket trovato.'));
}
return ListView.builder(
controller: _scrollController,
itemCount: state.hasReachedMax
? state.tickets.length
: state.tickets.length + 1,
itemBuilder: (context, index) {
// Se siamo all'ultimo elemento e non abbiamo raggiunto il max, mostriamo il loader
if (index >= state.tickets.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
),
);
}
final ticket = state.tickets[index];
return _TicketCard(ticket: ticket);
},
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
// TODO: Navigare alla creazione di un nuovo ticket
},
icon: const Icon(Icons.add),
label: const Text('Nuovo Ticket'),
),
);
}
// Widget di supporto per creare le Chip di filtro
Widget _buildStatusChip(
BuildContext context,
TicketListState state,
TicketStatus? status,
String label,
) {
final isSelected = state.statusFilter == status;
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: ChoiceChip(
label: Text(label),
selected: isSelected,
selectedColor:
status?.color.withValues(alpha: 0.2) ??
Colors.blue.withValues(alpha: 0.2),
onSelected: (selected) {
context.read<TicketListCubit>().updateFilters(
statusFilter: selected ? status : null,
clearStatus: !selected && status != null,
);
},
),
);
}
}
// ---------------------------------------------------------
// LA CARD DEL TICKET (Il "Colpo d'Occhio")
// ---------------------------------------------------------
class _TicketCard extends StatelessWidget {
final TicketModel ticket;
const _TicketCard({required this.ticket});
@override
Widget build(BuildContext context) {
final statusColor = ticket.ticketStatus.color;
final statusIcon = ticket.ticketStatus.icon;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
clipBehavior: Clip
.antiAlias, // Serve per tagliare il container laterale con gli angoli della card
child: IntrinsicHeight(
// Serve per far sì che il container laterale prenda tutta l'altezza
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// LA STRISCIA COLORATA LATERALE
Container(width: 6, color: statusColor),
Expanded(
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8.0,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
ticket.customerName ?? 'Cliente Sconosciuto',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
overflow: TextOverflow.ellipsis,
),
),
// IL BADGE DELLO STATO
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: statusColor.withValues(alpha: 0.5),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
ticket.ticketStatus.displayValue,
style: TextStyle(
fontSize: 12,
color: statusColor,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
// MODELLO O TIPO DI INTERVENTO
Text(
ticket.targetModelName ?? ticket.ticketType.displayValue,
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 4),
// DATA CREAZIONE (Es: 04/05/2026)
Text(
ticket.createdAt != null
? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}'
: '',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 12,
),
),
],
),
onTap: () {
// TODO: Aprire il dettaglio del ticket!
},
),
),
],
),
),
);
}
}