reworked operation (#12)
Reviewed-on: #12 Co-authored-by: Mark M2 Macbook <marco@catelli.it> Co-committed-by: Mark M2 Macbook <marco@catelli.it>
This commit is contained in:
389
lib/features/operations/blocs/operation_files_bloc.dart
Normal file
389
lib/features/operations/blocs/operation_files_bloc.dart
Normal file
@@ -0,0 +1,389 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
81
lib/features/operations/blocs/operation_files_events.dart
Normal file
81
lib/features/operations/blocs/operation_files_events.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
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 {}
|
||||
52
lib/features/operations/blocs/operation_files_state.dart
Normal file
52
lib/features/operations/blocs/operation_files_state.dart
Normal file
@@ -0,0 +1,52 @@
|
||||
part of 'operation_files_bloc.dart';
|
||||
|
||||
enum OperationFilesStatus { initial, loading, uploading, success, failure }
|
||||
|
||||
class OperationFilesState extends Equatable {
|
||||
const OperationFilesState({
|
||||
this.operationId,
|
||||
required this.status,
|
||||
this.error,
|
||||
this.localFiles = const [],
|
||||
this.remoteFiles = const [],
|
||||
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,
|
||||
status,
|
||||
error,
|
||||
localFiles,
|
||||
remoteFiles,
|
||||
selectedFiles,
|
||||
];
|
||||
|
||||
List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
|
||||
|
||||
OperationFilesState copyWith({
|
||||
String? operationId,
|
||||
OperationFilesStatus? status,
|
||||
String? error,
|
||||
List<AttachmentModel>? localFiles,
|
||||
List<AttachmentModel>? remoteFiles,
|
||||
List<AttachmentModel>? selectedFiles,
|
||||
}) {
|
||||
return OperationFilesState(
|
||||
operationId: operationId ?? this.operationId,
|
||||
status: status ?? this.status,
|
||||
error: error,
|
||||
localFiles: localFiles ?? this.localFiles,
|
||||
remoteFiles: remoteFiles ?? this.remoteFiles,
|
||||
selectedFiles: selectedFiles ?? this.selectedFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
304
lib/features/operations/blocs/operations_cubit.dart
Normal file
304
lib/features/operations/blocs/operations_cubit.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/features/operations/data/operations_repository.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
part 'operations_state.dart';
|
||||
|
||||
class OperationsCubit extends Cubit<OperationsState> {
|
||||
final OperationsRepository _repository = GetIt.I<OperationsRepository>();
|
||||
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
|
||||
final Uuid _uuid = const Uuid(); // Generatore di UUID per il batch
|
||||
|
||||
OperationsCubit()
|
||||
: super(const OperationsState(status: OperationsStatus.initial));
|
||||
|
||||
// --- CARICAMENTO E PAGINAZIONE ---
|
||||
|
||||
Future<void> loadOperations({bool refresh = false}) async {
|
||||
if (state.status == OperationsStatus.loading) return;
|
||||
if (!refresh && state.hasReachedMax) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.loading,
|
||||
errorMessage: null,
|
||||
allOperations: refresh ? [] : state.allOperations,
|
||||
hasReachedMax: refresh ? false : state.hasReachedMax,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final currentOffset = refresh ? 0 : state.allOperations.length;
|
||||
final companyId = _sessionCubit.state.company?.id;
|
||||
|
||||
if (companyId == null) {
|
||||
throw Exception("Company ID non trovato nella sessione");
|
||||
}
|
||||
|
||||
final newOperations = await _repository.fetchOperations(
|
||||
companyId: companyId,
|
||||
offset: currentOffset,
|
||||
limit: 50,
|
||||
searchTerm: state.query,
|
||||
dateRange: state.dateRange,
|
||||
);
|
||||
|
||||
final bool reachedMax = newOperations.length < 50;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.ready,
|
||||
allOperations: refresh
|
||||
? newOperations
|
||||
: [...state.allOperations, ...newOperations],
|
||||
hasReachedMax: reachedMax,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.failure,
|
||||
errorMessage: "Errore nel caricamento operazioni: $e",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- GESTIONE FILTRI ---
|
||||
|
||||
void updateFilters({String? query, DateTimeRange? range}) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
query: query ?? state.query,
|
||||
dateRange: range ?? state.dateRange,
|
||||
),
|
||||
);
|
||||
loadOperations(refresh: true);
|
||||
}
|
||||
|
||||
void clearFilters() {
|
||||
emit(state.copyWith(query: '', dateRange: null));
|
||||
loadOperations(refresh: true);
|
||||
}
|
||||
|
||||
void initOperationForm({
|
||||
OperationModel? existingOperation,
|
||||
String? operationId,
|
||||
String? staffId,
|
||||
String? staffDisplayName,
|
||||
}) async {
|
||||
if (existingOperation != null) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentOperation: existingOperation,
|
||||
status: OperationsStatus.ready,
|
||||
),
|
||||
);
|
||||
} else if (operationId != null) {
|
||||
OperationModel? operationModel = state.allOperations.firstWhereOrNull(
|
||||
(s) => s.id == operationId,
|
||||
);
|
||||
operationModel ??= await _repository.fetchOperationById(operationId);
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentOperation: operationModel,
|
||||
status: OperationsStatus.ready,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// NUOVA PRATICA: Creiamo un nuovo Batch UUID
|
||||
emit(
|
||||
state.copyWith(
|
||||
currentOperation: OperationModel(
|
||||
storeId: _sessionCubit.state.currentStore?.id ?? '',
|
||||
reference: '',
|
||||
createdAt: DateTime.now(),
|
||||
companyId: _sessionCubit.state.company!.id!,
|
||||
status: OperationStatus.draft,
|
||||
batchUuid: _uuid.v4(), // <-- GENERIAMO IL BATCH UNIVOCO
|
||||
),
|
||||
status: OperationsStatus.ready,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// MAGIA PURA: Prepara il form per inserire un altro servizio nella stessa pratica.
|
||||
/// Mantiene il Cliente, il Batch e lo Store, ma svuota il resto.
|
||||
void prepareNextOperationInBatch() {
|
||||
if (state.currentOperation == null) return;
|
||||
|
||||
final current = state.currentOperation!;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.ready,
|
||||
currentOperation: OperationModel(
|
||||
companyId: current.companyId,
|
||||
storeId: current.storeId,
|
||||
storeDisplayName: current.storeDisplayName,
|
||||
batchUuid: current.batchUuid, // <-- MANTIENE IL COLLEGAMENTO
|
||||
customerId: current.customerId, // <-- MANTIENE IL CLIENTE
|
||||
customerDisplayName: current.customerDisplayName,
|
||||
status: OperationStatus.draft,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- PERSISTENZA ---
|
||||
|
||||
Future<void> saveCurrentOperation({
|
||||
required OperationStatus targetStatus,
|
||||
bool shouldPop = true,
|
||||
}) async {
|
||||
if (state.currentOperation == null) return;
|
||||
|
||||
emit(state.copyWith(status: OperationsStatus.saving, errorMessage: null));
|
||||
try {
|
||||
final operationToSave = state.currentOperation!.copyWith(
|
||||
status: targetStatus,
|
||||
);
|
||||
|
||||
final updatedOperation = await _repository.saveFullOperation(
|
||||
operation: operationToSave,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
// Se non facciamo Pop (es. l'utente vuole aggiungere un altro servizio), non killiamo l'operazione corrente
|
||||
status: shouldPop
|
||||
? OperationsStatus.saved
|
||||
: OperationsStatus.savedNoPop,
|
||||
currentOperation: shouldPop ? null : updatedOperation,
|
||||
),
|
||||
);
|
||||
|
||||
// Ricarica in background per la dashboard
|
||||
loadOperations(refresh: true);
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: OperationsStatus.failure,
|
||||
errorMessage: e.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO OPERAZIONI DELLO STESSO BATCH (Per UI di riepilogo) ---
|
||||
|
||||
/// Puoi usare questa funzione se nella UI vuoi mostrare "Hai inserito 3 servizi in questa pratica"
|
||||
List<OperationModel> getOperationsInCurrentBatch() {
|
||||
if (state.currentOperation == null) return [];
|
||||
final currentBatch = state.currentOperation!.batchUuid;
|
||||
|
||||
// Filtriamo dalla lista caricata (o potresti fare una query diretta a Supabase se preferisci)
|
||||
return state.allOperations
|
||||
.where(
|
||||
(op) =>
|
||||
op.batchUuid == currentBatch &&
|
||||
op.id != state.currentOperation!.id,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// --- GESTIONE DELLO STATO DEL FORM IN TEMPO REALE ---
|
||||
void updateOperationFields({
|
||||
String? customerId,
|
||||
String? customerDisplayName,
|
||||
String? type,
|
||||
String? providerId,
|
||||
String? providerDisplayName,
|
||||
String? subtype,
|
||||
String? description,
|
||||
DateTime? expirationDate,
|
||||
int? quantity,
|
||||
String? modelId,
|
||||
String? modelDisplayName,
|
||||
String? staffId,
|
||||
String? staffDisplayName,
|
||||
// Aggiungiamo questi flag per forzare la pulizia dei campi quando cambi tipo
|
||||
bool clearProvider = false,
|
||||
bool clearType = false,
|
||||
bool clearSubtype = false,
|
||||
bool clearDescription = false,
|
||||
bool clearExpiration = false,
|
||||
bool clearQuantity = false,
|
||||
bool clearModel = false,
|
||||
}) {
|
||||
if (state.currentOperation == null) return;
|
||||
|
||||
final current = state.currentOperation!;
|
||||
|
||||
// Creiamo il modello aggiornato
|
||||
// ATTENZIONE: adatta questa logica in base a come è scritto il tuo copyWith!
|
||||
int? newQuantity;
|
||||
if (clearQuantity) {
|
||||
newQuantity = 1;
|
||||
}
|
||||
if (quantity != null && quantity <= 0) {
|
||||
newQuantity = 0;
|
||||
}
|
||||
if (quantity != null && quantity > 0) {
|
||||
newQuantity = quantity;
|
||||
}
|
||||
final updated = current.copyWith(
|
||||
customerId: customerId,
|
||||
customerDisplayName: customerDisplayName,
|
||||
|
||||
// Se clearProvider è true, forziamo una stringa vuota (o null se il tuo modello lo supporta)
|
||||
providerId: clearProvider ? null : (providerId ?? current.providerId),
|
||||
providerDisplayName: clearProvider
|
||||
? null
|
||||
: (providerDisplayName ?? current.providerDisplayName),
|
||||
quantity: newQuantity,
|
||||
type: clearType ? null : (type ?? current.type),
|
||||
description: clearDescription
|
||||
? null
|
||||
: (description ?? current.description),
|
||||
subtype: clearSubtype ? null : (subtype ?? current.subtype),
|
||||
expirationDate: clearExpiration
|
||||
? null
|
||||
: (expirationDate ?? current.expirationDate),
|
||||
modelId: clearModel ? null : (modelId ?? current.modelId),
|
||||
modelDisplayName: clearModel
|
||||
? null
|
||||
: (modelDisplayName ?? current.modelDisplayName),
|
||||
staffId: staffId ?? current.staffId,
|
||||
staffDisplayName: staffDisplayName ?? current.staffDisplayName,
|
||||
);
|
||||
|
||||
emit(state.copyWith(currentOperation: updated));
|
||||
}
|
||||
|
||||
// Metodo di utilità per calcolare la data X mesi da oggi
|
||||
DateTime _calculateMonths(int months) {
|
||||
final now = DateTime.now();
|
||||
return DateTime(now.year, now.month + months, now.day);
|
||||
}
|
||||
|
||||
// Quando l'utente seleziona un tipo, impostiamo il default
|
||||
void setTypeWithSmartDefault(String type) {
|
||||
DateTime? defaultDate;
|
||||
|
||||
if (type == 'Energy') defaultDate = _calculateMonths(24);
|
||||
if (type == 'Fin') defaultDate = _calculateMonths(30);
|
||||
if (type == 'Entertainment') defaultDate = _calculateMonths(12);
|
||||
|
||||
updateOperationFields(
|
||||
type: type,
|
||||
expirationDate: defaultDate,
|
||||
clearProvider: true,
|
||||
clearSubtype: true,
|
||||
clearModel: true,
|
||||
clearQuantity: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/features/operations/blocs/operations_state.dart
Normal file
68
lib/features/operations/blocs/operations_state.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
part of 'operations_cubit.dart';
|
||||
|
||||
enum OperationsStatus {
|
||||
initial,
|
||||
loading,
|
||||
ready,
|
||||
saving,
|
||||
saved,
|
||||
savedNoPop,
|
||||
success,
|
||||
failure,
|
||||
}
|
||||
|
||||
class OperationsState extends Equatable {
|
||||
final OperationsStatus status;
|
||||
final List<OperationModel> allOperations;
|
||||
final OperationModel? currentOperation; // La bozza che stiamo editando
|
||||
final String? errorMessage;
|
||||
final String query;
|
||||
final DateTimeRange? dateRange;
|
||||
final bool hasReachedMax;
|
||||
final bool isSavingDraft;
|
||||
|
||||
const OperationsState({
|
||||
required this.status,
|
||||
this.allOperations = const [],
|
||||
this.currentOperation,
|
||||
this.errorMessage,
|
||||
this.query = '',
|
||||
this.dateRange,
|
||||
this.hasReachedMax = false,
|
||||
this.isSavingDraft = false,
|
||||
});
|
||||
|
||||
OperationsState copyWith({
|
||||
OperationsStatus? status,
|
||||
List<OperationModel>? allOperations,
|
||||
OperationModel? currentOperation,
|
||||
String? errorMessage,
|
||||
String? query,
|
||||
DateTimeRange? dateRange,
|
||||
bool? hasReachedMax,
|
||||
bool? isSavingDraft,
|
||||
}) {
|
||||
return OperationsState(
|
||||
status: status ?? this.status,
|
||||
allOperations: allOperations ?? this.allOperations,
|
||||
currentOperation: currentOperation ?? this.currentOperation,
|
||||
errorMessage: errorMessage,
|
||||
query: query ?? this.query,
|
||||
dateRange: dateRange ?? this.dateRange,
|
||||
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
|
||||
isSavingDraft: isSavingDraft ?? this.isSavingDraft,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
allOperations,
|
||||
currentOperation,
|
||||
errorMessage,
|
||||
query,
|
||||
dateRange,
|
||||
hasReachedMax,
|
||||
isSavingDraft,
|
||||
];
|
||||
}
|
||||
305
lib/features/operations/data/operations_repository.dart
Normal file
305
lib/features/operations/data/operations_repository.dart
Normal file
@@ -0,0 +1,305 @@
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flux/core/blocs/session/session_cubit.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import '../models/operation_model.dart';
|
||||
|
||||
class OperationsRepository {
|
||||
final _supabase = Supabase.instance.client;
|
||||
final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
|
||||
|
||||
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
|
||||
Future<OperationModel> fetchOperationById(String id) async {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('operation')
|
||||
.select('''
|
||||
*,
|
||||
customer(name),
|
||||
store(name),
|
||||
staff_member(name),
|
||||
provider(name),
|
||||
model(name_with_brand),
|
||||
attachment(*)
|
||||
''')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
return OperationModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw Exception('Errore nel caricamento del servizio: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO PAGINATO CON FILTRI E JOIN ---
|
||||
Future<List<OperationModel>> fetchOperations({
|
||||
required String companyId,
|
||||
required int offset,
|
||||
int limit = 50,
|
||||
String? searchTerm,
|
||||
DateTimeRange? dateRange,
|
||||
}) async {
|
||||
try {
|
||||
var query = _supabase
|
||||
.from('operation')
|
||||
.select('''
|
||||
*,
|
||||
customer(name),
|
||||
store(name),
|
||||
provider(name),
|
||||
model(name_with_brand),
|
||||
staff_member(name),
|
||||
attachment(*)
|
||||
''')
|
||||
.eq('company_id', companyId);
|
||||
|
||||
// Filtro Range Date
|
||||
if (dateRange != null) {
|
||||
query = query
|
||||
.gte('created_at', dateRange.start.toIso8601String())
|
||||
.lte('created_at', dateRange.end.toIso8601String());
|
||||
}
|
||||
|
||||
if (searchTerm != null && searchTerm.isNotEmpty) {
|
||||
// Filtra sui campi della tabella principale O su quelli della tabella joinata
|
||||
query = query.or(
|
||||
'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%',
|
||||
);
|
||||
}
|
||||
|
||||
final response = await query
|
||||
.order('created_at', ascending: false)
|
||||
.range(offset, offset + limit - 1);
|
||||
|
||||
return (response as List)
|
||||
.map((map) => OperationModel.fromMap(map))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
Stream<List<OperationModel>> getLastStoreOperationsStream({
|
||||
required String storeId,
|
||||
required int limit,
|
||||
}) {
|
||||
return _supabase
|
||||
.from('operation')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('store_id', storeId)
|
||||
.order('created_at', ascending: false)
|
||||
.limit(limit)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => OperationModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// --- SALVATAGGIO COMPLETO (PRIMA PADRE, POI FIGLI) ---
|
||||
Future<OperationModel> saveFullOperation({
|
||||
required OperationModel operation,
|
||||
}) async {
|
||||
try {
|
||||
// 1. Salvataggio classico dell'operazione corrente
|
||||
final response = await _supabase
|
||||
.from('operation')
|
||||
.upsert(operation.toMap())
|
||||
.select(
|
||||
'*, provider(*), model(*), store(*), staff_member(*), customer(*), attachment(*)',
|
||||
)
|
||||
.single();
|
||||
|
||||
final savedOperation = OperationModel.fromMap(response);
|
||||
|
||||
// 2. ALLINEAMENTO BATCH SEMPRE ATTIVO!
|
||||
if (operation.batchUuid.isNotEmpty) {
|
||||
await _supabase
|
||||
.from('operation')
|
||||
.update({'note': operation.note}) // Spalmiamo la nota attuale
|
||||
.eq(
|
||||
'batch_uuid',
|
||||
operation.batchUuid,
|
||||
); // Su tutte le pratiche di questo scontrino
|
||||
}
|
||||
|
||||
return savedOperation;
|
||||
} catch (e) {
|
||||
throw Exception("Errore nel salvataggio dell'operazione: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// --- ELIMINAZIONE ---
|
||||
Future<void> deleteOperation(String id) async {
|
||||
try {
|
||||
await _supabase.from('operation').delete().eq('id', id);
|
||||
} catch (e) {
|
||||
throw Exception('$e');
|
||||
}
|
||||
}
|
||||
|
||||
// --- RECUPERO TIPI CONTENUTI PIÙ FREQUENTI PER AUTOCOMPLETE ---
|
||||
Future<List<String>> fetchTopEntertainmentTypes(String companyId) async {
|
||||
try {
|
||||
// Cerchiamo i tipi più frequenti associati ai servizi di questa company
|
||||
// Nota: dobbiamo passare attraverso la tabella 'operation' per filtrare per company_id
|
||||
final response = await _supabase
|
||||
.from('operation')
|
||||
.select('description')
|
||||
.eq('company_id', companyId)
|
||||
.eq('type', 'Entertainment')
|
||||
.limit(50); // Prendiamo un campione
|
||||
|
||||
// Logica rapida per contare le occorrenze e prendere i primi 5
|
||||
final Map<String, int> counts = {};
|
||||
for (var item in (response as List)) {
|
||||
final description = item['description'] as String;
|
||||
counts[description] = (counts[description] ?? 0) + 1;
|
||||
}
|
||||
|
||||
var sortedKeys = counts.keys.toList()
|
||||
..sort((a, b) => counts[b]!.compareTo(counts[a]!));
|
||||
|
||||
return sortedKeys.take(5).toList();
|
||||
} catch (e) {
|
||||
return [
|
||||
"Netflix",
|
||||
"DAZN",
|
||||
"Disney+",
|
||||
"Sky",
|
||||
]; // Fallback se non c'è ancora storia
|
||||
}
|
||||
}
|
||||
|
||||
/// Ascolta in tempo reale i file caricati per una pratica
|
||||
Stream<List<AttachmentModel>> getOperationFilesStream(String operationId) {
|
||||
return _supabase
|
||||
.from('attachment')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('operation_id', operationId)
|
||||
.order('created_at', ascending: false)
|
||||
.map(
|
||||
(listOfMaps) =>
|
||||
listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AttachmentModel> uploadAndRegisterOperationFile({
|
||||
required String operationId,
|
||||
required PlatformFile pickedFile,
|
||||
}) async {
|
||||
final cleanFileName = pickedFile.name.replaceAll(
|
||||
RegExp(r'[^a-zA-Z0-9\.\-]'),
|
||||
'_',
|
||||
);
|
||||
final storagePath =
|
||||
'$companyId/operations/$operationId/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
|
||||
final int fileSize = pickedFile.size;
|
||||
final fileToSave = AttachmentModel(
|
||||
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
|
||||
operationId: operationId,
|
||||
name: cleanFileName.fileNameWithoutExtension(),
|
||||
extension: cleanFileName.fileExtension(),
|
||||
storagePath: storagePath,
|
||||
fileSize: fileSize,
|
||||
);
|
||||
final String mimeType = fileToSave.extension.toLowerCase() == 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'image/${fileToSave.extension}';
|
||||
try {
|
||||
// Usiamo bytes invece del path per massima compatibilità
|
||||
if (pickedFile.bytes == null && pickedFile.path == null) {
|
||||
throw 'Impossibile leggere il contenuto del file';
|
||||
}
|
||||
|
||||
// Se siamo su desktop/mobile abbiamo il path, su web abbiamo i bytes
|
||||
if (pickedFile.bytes != null) {
|
||||
await _supabase.storage
|
||||
.from('documents')
|
||||
.uploadBinary(
|
||||
storagePath,
|
||||
pickedFile.bytes!,
|
||||
fileOptions: FileOptions(contentType: mimeType, upsert: true),
|
||||
);
|
||||
}
|
||||
|
||||
final response = await _supabase
|
||||
.from('attachment')
|
||||
.insert(fileToSave.toMap())
|
||||
.select()
|
||||
.single();
|
||||
|
||||
return AttachmentModel.fromMap(response);
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'upload: $e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> copyFileToCustomer({
|
||||
required AttachmentModel file,
|
||||
required String customerId,
|
||||
}) async {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.update({'customer_id': customerId})
|
||||
.eq('id', file.id!);
|
||||
}
|
||||
|
||||
Future<void> renameAttachment(String id, String newName) async {
|
||||
try {
|
||||
await _supabase.from('attachment').update({'name': newName}).eq('id', id);
|
||||
} catch (e) {
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSpecificOperationFile(AttachmentModel file) async {
|
||||
try {
|
||||
if (file.customerId == null) {
|
||||
await _supabase.from('attachment').delete().eq('id', file.id!);
|
||||
await _supabase.storage.from('documents').remove([file.storagePath!]);
|
||||
} else {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.update({'operation_id': null})
|
||||
.eq('id', file.id!);
|
||||
}
|
||||
} catch (e) {
|
||||
throw '$e';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteOperationFiles(List<AttachmentModel> files) async {
|
||||
if (files.isEmpty) return;
|
||||
// 1. Prepariamo le liste di ID e di Percorsi
|
||||
final List<String> idsToDelete = [];
|
||||
final List<String> idsToEdit = [];
|
||||
final List<String> storagePathsToDelete = [];
|
||||
for (var file in files) {
|
||||
if (file.customerId == null) {
|
||||
idsToDelete.add(file.id!);
|
||||
storagePathsToDelete.add(file.storagePath!);
|
||||
} else {
|
||||
idsToEdit.add(file.id!);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (idsToDelete.isNotEmpty) {
|
||||
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
|
||||
await _supabase.storage.from('documents').remove(storagePathsToDelete);
|
||||
}
|
||||
if (idsToEdit.isNotEmpty) {
|
||||
await _supabase
|
||||
.from('attachment')
|
||||
.update({'operation_id': null})
|
||||
.inFilter('id', idsToEdit);
|
||||
}
|
||||
} on PostgrestException catch (e) {
|
||||
throw 'Errore database: ${e.message}';
|
||||
} catch (e) {
|
||||
throw 'Errore durante l\'eliminazione dei file: $e';
|
||||
}
|
||||
}
|
||||
}
|
||||
248
lib/features/operations/models/operation_model.dart
Normal file
248
lib/features/operations/models/operation_model.dart
Normal file
@@ -0,0 +1,248 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/core/utils/extensions.dart';
|
||||
import 'package:flux/features/attachments/models/attachment_model.dart';
|
||||
|
||||
enum OperationStatus {
|
||||
ok('ok'),
|
||||
waitingforaction('waiting_for_action'),
|
||||
waitingforsupport('waiting_for_support'),
|
||||
waitingfordeployment('waiting_for_deployment'),
|
||||
ko('ko'),
|
||||
draft('draft'),
|
||||
canceled('canceled');
|
||||
|
||||
static OperationStatus fromString(String value) {
|
||||
final normalizedValue = value.replaceAll('_', '').toLowerCase();
|
||||
return OperationStatus.values.firstWhere(
|
||||
(e) => e.name.toLowerCase() == normalizedValue,
|
||||
);
|
||||
}
|
||||
|
||||
final String supabaseName;
|
||||
|
||||
const OperationStatus(this.supabaseName);
|
||||
}
|
||||
|
||||
class OperationModel extends Equatable {
|
||||
final String? id;
|
||||
final DateTime? createdAt;
|
||||
final String type;
|
||||
final String? subtype;
|
||||
final String? providerId;
|
||||
final String? providerDisplayName;
|
||||
final String? modelId;
|
||||
final String? modelDisplayName;
|
||||
final String? description;
|
||||
final DateTime? expirationDate;
|
||||
final String note;
|
||||
final bool showInDashboard;
|
||||
final String batchUuid;
|
||||
final String companyId;
|
||||
final String storeId;
|
||||
final String? storeDisplayName;
|
||||
final int quantity;
|
||||
final String? staffId;
|
||||
final String? staffDisplayName;
|
||||
final String? lastCampaignId;
|
||||
final OperationStatus status;
|
||||
final String? customerId;
|
||||
final String? customerDisplayName;
|
||||
final String reference;
|
||||
|
||||
// ALLEGATI (Aggiunto)
|
||||
final List<AttachmentModel> attachments;
|
||||
|
||||
const OperationModel({
|
||||
this.id,
|
||||
this.createdAt,
|
||||
this.type = '',
|
||||
this.subtype,
|
||||
this.providerId,
|
||||
this.providerDisplayName,
|
||||
this.modelId,
|
||||
this.modelDisplayName,
|
||||
this.description,
|
||||
this.expirationDate,
|
||||
this.note = '',
|
||||
this.showInDashboard = true,
|
||||
this.batchUuid = '',
|
||||
required this.companyId,
|
||||
this.storeId = '',
|
||||
this.storeDisplayName,
|
||||
this.quantity = 1,
|
||||
this.staffId,
|
||||
this.staffDisplayName,
|
||||
this.lastCampaignId,
|
||||
this.status = OperationStatus.draft,
|
||||
this.customerId,
|
||||
this.customerDisplayName,
|
||||
this.reference = '',
|
||||
this.attachments = const [],
|
||||
});
|
||||
|
||||
OperationModel copyWith({
|
||||
String? id,
|
||||
DateTime? createdAt,
|
||||
String? type,
|
||||
String? subtype,
|
||||
String? providerId,
|
||||
String? providerDisplayName,
|
||||
String? modelId,
|
||||
String? modelDisplayName,
|
||||
String? description,
|
||||
DateTime? expirationDate,
|
||||
String? note,
|
||||
bool? showInDashboard,
|
||||
String? batchUuid,
|
||||
String? companyId,
|
||||
String? storeId,
|
||||
String? storeDisplayName,
|
||||
int? quantity,
|
||||
String? staffId,
|
||||
String? staffDisplayName,
|
||||
String? lastCampaignId,
|
||||
OperationStatus? status,
|
||||
String? customerId,
|
||||
String? customerDisplayName,
|
||||
String? reference,
|
||||
List<AttachmentModel>? attachments,
|
||||
}) => OperationModel(
|
||||
id: id ?? this.id,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
type: type ?? this.type,
|
||||
subtype: subtype ?? this.subtype,
|
||||
providerId: providerId ?? this.providerId,
|
||||
providerDisplayName: providerDisplayName ?? this.providerDisplayName,
|
||||
modelId: modelId ?? this.modelId,
|
||||
modelDisplayName: modelDisplayName ?? this.modelDisplayName,
|
||||
description: description ?? this.description,
|
||||
expirationDate: expirationDate ?? this.expirationDate,
|
||||
note: note ?? this.note,
|
||||
showInDashboard: showInDashboard ?? this.showInDashboard,
|
||||
batchUuid: batchUuid ?? this.batchUuid,
|
||||
companyId: companyId ?? this.companyId,
|
||||
storeId: storeId ?? this.storeId,
|
||||
storeDisplayName: storeDisplayName ?? this.storeDisplayName,
|
||||
quantity: quantity ?? this.quantity,
|
||||
staffId: staffId ?? this.staffId,
|
||||
staffDisplayName: staffDisplayName ?? this.staffDisplayName,
|
||||
lastCampaignId: lastCampaignId ?? this.lastCampaignId,
|
||||
status: status ?? this.status,
|
||||
customerId: customerId ?? this.customerId,
|
||||
customerDisplayName: customerDisplayName ?? this.customerDisplayName,
|
||||
reference: reference ?? this.reference,
|
||||
attachments: attachments ?? this.attachments,
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
createdAt,
|
||||
type,
|
||||
subtype,
|
||||
providerId,
|
||||
providerDisplayName,
|
||||
modelId,
|
||||
modelDisplayName,
|
||||
description,
|
||||
expirationDate,
|
||||
note,
|
||||
showInDashboard,
|
||||
batchUuid,
|
||||
companyId,
|
||||
storeId,
|
||||
storeDisplayName,
|
||||
quantity,
|
||||
staffId,
|
||||
staffDisplayName,
|
||||
lastCampaignId,
|
||||
status,
|
||||
customerId,
|
||||
customerDisplayName,
|
||||
reference,
|
||||
attachments,
|
||||
];
|
||||
|
||||
factory OperationModel.empty({required String companyId}) {
|
||||
return OperationModel(id: null, createdAt: null, companyId: companyId);
|
||||
}
|
||||
|
||||
factory OperationModel.fromMap(Map<String, dynamic> map) {
|
||||
return OperationModel(
|
||||
id: map['id'] as String?,
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'])
|
||||
: null,
|
||||
type: map['type'] as String? ?? '',
|
||||
subtype: map['sub_type'] as String?,
|
||||
|
||||
// I campi relazionali nullabili restano rigorosamente null!
|
||||
providerId: map['provider_id'] as String?,
|
||||
// MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti
|
||||
providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(),
|
||||
|
||||
modelId: map['model_id'] as String?,
|
||||
modelDisplayName: (map['model']?['name_with_brand'] as String?)
|
||||
?.myFormat(),
|
||||
|
||||
description: map['description'] as String?,
|
||||
expirationDate: map['expiration_date'] != null
|
||||
? DateTime.parse(map['expiration_date'])
|
||||
: null,
|
||||
note: map['note'] as String? ?? '',
|
||||
showInDashboard: map['show_in_dashboard'] as bool? ?? true,
|
||||
batchUuid: map['batch_uuid'] as String? ?? '',
|
||||
companyId: map['company_id'] as String,
|
||||
|
||||
storeId:
|
||||
map['store_id'] as String? ??
|
||||
'', // Questo è non-nullable nella tua classe
|
||||
storeDisplayName: (map['store']?['name'] as String?)?.myFormat(),
|
||||
|
||||
quantity: map['quantity'] is int
|
||||
? map['quantity']
|
||||
: int.tryParse(map['quantity']?.toString() ?? '1') ?? 1,
|
||||
|
||||
staffId: map['staff_id'] as String?,
|
||||
staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(),
|
||||
|
||||
lastCampaignId: map['last_campaign_id'] as String?,
|
||||
status: OperationStatus.fromString(map['status'] ?? 'draft'),
|
||||
|
||||
customerId: map['customer_id'] as String?,
|
||||
customerDisplayName: (map['customer']?['name'] as String?)?.myFormat(),
|
||||
|
||||
attachments:
|
||||
(map['attachment'] as List?)
|
||||
?.map((x) => AttachmentModel.fromMap(x))
|
||||
.toList() ??
|
||||
const [],
|
||||
|
||||
reference: map['reference'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
if (id != null) 'id': id,
|
||||
'type': type,
|
||||
'sub_type': subtype,
|
||||
'provider_id': providerId,
|
||||
'model_id': modelId,
|
||||
'description': description,
|
||||
if (expirationDate != null)
|
||||
'expiration_date': expirationDate!.toIso8601String(),
|
||||
'note': note,
|
||||
'show_in_dashboard': showInDashboard,
|
||||
'batch_uuid': batchUuid,
|
||||
'company_id': companyId,
|
||||
'store_id': storeId,
|
||||
'quantity': quantity,
|
||||
if (staffId != null) 'staff_id': staffId,
|
||||
if (lastCampaignId != null) 'last_campaign_id': lastCampaignId,
|
||||
'status': status.supabaseName,
|
||||
if (customerId != null) 'customer_id': customerId,
|
||||
'reference': reference,
|
||||
};
|
||||
}
|
||||
}
|
||||
76
lib/features/operations/ui/operation_action_card.dart
Normal file
76
lib/features/operations/ui/operation_action_card.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class OperationActionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
final Color color;
|
||||
final int count;
|
||||
const OperationActionCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
required this.color,
|
||||
this.count = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isActive = count > 0;
|
||||
|
||||
return Card(
|
||||
elevation: isActive ? 4 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: isActive ? color : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
width: 110, // Dimensione fissa per farle stare in una Row/Wrap
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: isActive ? color.withValues(alpha: 0.1) : Colors.transparent,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: isActive ? color : Colors.grey.shade400,
|
||||
size: 32,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: isActive ? FontWeight.bold : FontWeight.normal,
|
||||
color: isActive ? color : Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (isActive) ...[
|
||||
const SizedBox(height: 4),
|
||||
CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: color,
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: const TextStyle(fontSize: 10, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
481
lib/features/operations/ui/operation_form_screen.dart
Normal file
481
lib/features/operations/ui/operation_form_screen.dart
Normal file
@@ -0,0 +1,481 @@
|
||||
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/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/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';
|
||||
|
||||
class OperationFormScreen extends StatefulWidget {
|
||||
final String? operationId;
|
||||
final OperationModel? existingOperation;
|
||||
|
||||
const OperationFormScreen({
|
||||
super.key,
|
||||
this.operationId,
|
||||
this.existingOperation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<OperationFormScreen> createState() => _OperationFormScreenState();
|
||||
}
|
||||
|
||||
class _OperationFormScreenState extends State<OperationFormScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
final _referenceController = TextEditingController();
|
||||
final _noteController = TextEditingController();
|
||||
final _freeTextSubtypeController = TextEditingController();
|
||||
final _freeTextDescriptionController = TextEditingController();
|
||||
|
||||
final List<String> _availableTypes = [
|
||||
'AL',
|
||||
'MNP',
|
||||
'NIP',
|
||||
'UNICA',
|
||||
'TELEPASS',
|
||||
'Energy',
|
||||
'Fin',
|
||||
'Entertainment',
|
||||
'Custom',
|
||||
];
|
||||
|
||||
bool _isInitialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final cubit = context.read<OperationsCubit>();
|
||||
final currentLoggedStaff = GetIt.I
|
||||
.get<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember!;
|
||||
|
||||
// 1. Diciamo al Cubit di prepararsi
|
||||
cubit.initOperationForm(
|
||||
existingOperation: widget.existingOperation,
|
||||
operationId: widget.operationId,
|
||||
staffId: currentLoggedStaff.id,
|
||||
staffDisplayName: currentLoggedStaff.name,
|
||||
);
|
||||
|
||||
// 2. IL TRUCCO MAGICO:
|
||||
// Se abbiamo passato existingOperation, il Cubit si è appena aggiornato.
|
||||
// Lo stato è già pronto, quindi sincronizziamo i controller SUBITO!
|
||||
if (cubit.state.currentOperation != null) {
|
||||
_syncTextControllers(cubit.state.currentOperation!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_referenceController.dispose();
|
||||
_noteController.dispose();
|
||||
_freeTextSubtypeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncTextControllers(OperationModel model) {
|
||||
if (_referenceController.text.isEmpty && model.reference.isNotEmpty) {
|
||||
_referenceController.text = model.reference;
|
||||
}
|
||||
if (_noteController.text.isEmpty && model.note.isNotEmpty) {
|
||||
_noteController.text = model.note;
|
||||
}
|
||||
if (_freeTextSubtypeController.text.isEmpty &&
|
||||
model.subtype != null &&
|
||||
model.subtype!.isNotEmpty) {
|
||||
_freeTextSubtypeController.text = model.subtype!;
|
||||
}
|
||||
if (_freeTextDescriptionController.text.isEmpty &&
|
||||
model.description != null &&
|
||||
model.description!.isNotEmpty) {
|
||||
_freeTextDescriptionController.text = model.description!;
|
||||
}
|
||||
_isInitialized = true;
|
||||
}
|
||||
|
||||
void _saveOperation({required bool keepAdding}) {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
final cubit = context.read<OperationsCubit>();
|
||||
final currentOperation = cubit.state.currentOperation!;
|
||||
|
||||
final operationToSave = currentOperation.copyWith(
|
||||
reference: _referenceController.text,
|
||||
note: _noteController.text,
|
||||
subtype: ['Entertainment', 'Custom'].contains(currentOperation.type)
|
||||
? _freeTextSubtypeController.text
|
||||
: currentOperation.subtype,
|
||||
description: ['Energy', 'Custom'].contains(currentOperation.type)
|
||||
? _freeTextDescriptionController.text
|
||||
: currentOperation.description,
|
||||
);
|
||||
|
||||
cubit.initOperationForm(existingOperation: operationToSave);
|
||||
cubit.saveCurrentOperation(
|
||||
targetStatus: OperationStatus.ok,
|
||||
shouldPop: !keepAdding,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return BlocConsumer<OperationsCubit, OperationsState>(
|
||||
listenWhen: (previous, current) =>
|
||||
previous.status != current.status ||
|
||||
previous.currentOperation?.id != current.currentOperation?.id,
|
||||
listener: (context, state) {
|
||||
if (state.status == OperationsStatus.ready &&
|
||||
state.currentOperation != null &&
|
||||
!_isInitialized) {
|
||||
_syncTextControllers(state.currentOperation!);
|
||||
}
|
||||
|
||||
if (state.status == OperationsStatus.saved) {
|
||||
Navigator.of(context).pop();
|
||||
} else if (state.status == OperationsStatus.savedNoPop) {
|
||||
context.read<OperationsCubit>().prepareNextOperationInBatch();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Servizio aggiunto! Inserisci il prossimo.'),
|
||||
),
|
||||
);
|
||||
_freeTextSubtypeController.clear();
|
||||
_freeTextDescriptionController.clear();
|
||||
} else if (state.status == OperationsStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Errore'),
|
||||
backgroundColor: theme.colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
if (!_isInitialized &&
|
||||
(widget.operationId != null || widget.existingOperation != null) &&
|
||||
state.status == OperationsStatus.loading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
state.currentOperation?.id == null
|
||||
? 'Nuova Pratica'
|
||||
: 'Modifica Pratica',
|
||||
),
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isUltraWide = constraints.maxWidth > 1400;
|
||||
final isDesktop = constraints.maxWidth > 900;
|
||||
if (isUltraWide) {
|
||||
// --- LAYOUT 3 COLONNE (Schermi giganti) ---
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 1. FORM PRINCIPALE (40%)
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
// Attenzione: devi togliere la sezione file dal _buildMainFormContent!
|
||||
child: _buildMainFormContent(
|
||||
theme,
|
||||
state,
|
||||
showFiles: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||
|
||||
// 2. NOTE (30%)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildNotesSection(isDesktop: true),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||
|
||||
// 3. FILE (30%)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: OperationFilesSection(
|
||||
currentOp: state.currentOperation!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else if (isDesktop) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildMainFormContent(theme, state),
|
||||
),
|
||||
),
|
||||
VerticalDivider(width: 1, color: theme.dividerColor),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _buildNotesSection(isDesktop: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMainFormContent(theme, state),
|
||||
const Divider(height: 32),
|
||||
_buildNotesSection(isDesktop: false),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton(
|
||||
onPressed: state.status == OperationsStatus.saving
|
||||
? null
|
||||
: () => _saveOperation(keepAdding: true),
|
||||
child: const Text(
|
||||
'Salva e Aggiungi Altro',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: ElevatedButton(
|
||||
onPressed: state.status == OperationsStatus.saving
|
||||
? null
|
||||
: () => _saveOperation(keepAdding: false),
|
||||
child: state.status == OperationsStatus.saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Salva ed Esci'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMainFormContent(
|
||||
ThemeData theme,
|
||||
OperationsState state, {
|
||||
bool showFiles = true,
|
||||
}) {
|
||||
final currentOp = state.currentOperation;
|
||||
final currentType = currentOp?.type ?? 'AL';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StaffSection(currentOp: currentOp),
|
||||
const Divider(height: 50),
|
||||
_buildSectionTitle('Cliente & Riferimento'),
|
||||
CustomerSection(currentOp: currentOp),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _referenceController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Riferimento (es. numero di telefono, targa...)',
|
||||
prefixIcon: Icon(Icons.tag),
|
||||
),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
_buildSectionTitle('Cosa stiamo facendo?'),
|
||||
Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: _availableTypes.map((type) {
|
||||
return ChoiceChip(
|
||||
label: Text(type),
|
||||
selected: currentType == type,
|
||||
onSelected: (selected) {
|
||||
if (selected) {
|
||||
context.read<OperationsCubit>().setTypeWithSmartDefault(type);
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
_buildSectionTitle('Dettagli Servizio'),
|
||||
DetailsSection(
|
||||
currentOp: currentOp,
|
||||
currentType: currentType,
|
||||
freeTextSubtypeController: _freeTextSubtypeController,
|
||||
freeTextDescriptionController: _freeTextDescriptionController,
|
||||
durationQuickPicks: _buildDurationQuickPicks(currentOp),
|
||||
),
|
||||
|
||||
// QUANTITÀ
|
||||
Row(
|
||||
children: [
|
||||
const Text('Quantità: '),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.remove),
|
||||
onPressed: () {
|
||||
final q = currentOp?.quantity ?? 1;
|
||||
if (q > 1) {
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
quantity: q - 1,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'${currentOp?.quantity ?? 1}',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
final q = currentOp?.quantity ?? 1;
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
quantity: q + 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 32),
|
||||
|
||||
if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDurationQuickPicks(OperationModel? currentOp) {
|
||||
final durations = [3, 6, 12, 24, 30, 36, 48];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"Imposta durata rapida (mesi):",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: durations.map((months) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ActionChip(
|
||||
label: Text("$months m"),
|
||||
backgroundColor: Colors.blue.withValues(alpha: 0.05),
|
||||
onPressed: () {
|
||||
final now = DateTime.now();
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
expirationDate: DateTime(
|
||||
now.year,
|
||||
now.month + months,
|
||||
now.day,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNotesSection({required bool isDesktop}) {
|
||||
final title = _buildSectionTitle('Note Interne');
|
||||
final noteField = TextFormField(
|
||||
controller: _noteController,
|
||||
keyboardType: TextInputType.multiline,
|
||||
minLines: isDesktop ? null : 5,
|
||||
maxLines: null,
|
||||
expands: isDesktop,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Incolla qui seriali, ICCID, IBAN, indirizzi...',
|
||||
alignLabelWithHint: true,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
return isDesktop
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
title,
|
||||
const SizedBox(height: 8),
|
||||
Expanded(child: noteField),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [title, const SizedBox(height: 8), noteField],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
303
lib/features/operations/ui/operation_mobile_upload_screen.dart
Normal file
303
lib/features/operations/ui/operation_mobile_upload_screen.dart
Normal file
@@ -0,0 +1,303 @@
|
||||
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"!
|
||||
}
|
||||
}
|
||||
200
lib/features/operations/ui/operations_screen.dart
Normal file
200
lib/features/operations/ui/operations_screen.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flux/features/operations/blocs/operations_cubit.dart';
|
||||
import 'package:flux/features/operations/models/operation_model.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
// Importa i tuoi modelli e cubit
|
||||
|
||||
class OperationsScreen extends StatefulWidget {
|
||||
const OperationsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<OperationsScreen> createState() => _OperationsScreenState();
|
||||
}
|
||||
|
||||
class _OperationsScreenState extends State<OperationsScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Agganciamo il listener per la paginazione (Scroll Infinito)
|
||||
_scrollController.addListener(_onScroll);
|
||||
// Carichiamo i servizi iniziali
|
||||
context.read<OperationsCubit>().loadOperations();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_isBottom) {
|
||||
context.read<OperationsCubit>().loadOperations();
|
||||
}
|
||||
}
|
||||
|
||||
bool get _isBottom {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||
final currentScroll = _scrollController.offset;
|
||||
// Carica quando mancano 200px alla fine
|
||||
return currentScroll >= (maxScroll * 0.9);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Gestione Servizi"),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search),
|
||||
onPressed: () {
|
||||
// Qui potrai implementare una barra di ricerca
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: BlocBuilder<OperationsCubit, OperationsState>(
|
||||
builder: (context, state) {
|
||||
// 1. Stato di caricamento iniziale
|
||||
if (state.status == OperationsStatus.loading &&
|
||||
state.allOperations.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// 2. Lista vuota
|
||||
if (state.allOperations.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text("Nessuna pratica trovata."),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () => context
|
||||
.read<OperationsCubit>()
|
||||
.loadOperations(refresh: true),
|
||||
child: const Text("Riprova"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 3. La Lista (con Pull-to-refresh)
|
||||
return RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
context.read<OperationsCubit>().loadOperations(refresh: true),
|
||||
child: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.only(bottom: 80), // Spazio per il FAB
|
||||
itemCount: state.hasReachedMax
|
||||
? state.allOperations.length
|
||||
: state.allOperations.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= state.allOperations.length) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final operation = state.allOperations[index];
|
||||
return _buildOperationCard(context, operation);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => startNewOperation(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOperationCard(BuildContext context, OperationModel operation) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
operation.customerDisplayName ?? "Cliente sconosciuto",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
"Pratica: ${operation.reference} • ${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}",
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Text(operation.type),
|
||||
const SizedBox(width: 8),
|
||||
_buildOperationStatus(operation.status),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.pushNamed(
|
||||
'operation-form',
|
||||
extra: operation, // <-- LA MAGIA È QUI: Passa l'oggetto intero!
|
||||
// Teniamo anche il parametro URL per coerenza di routing
|
||||
queryParameters: operation.id != null
|
||||
? {'operationId': operation.id!}
|
||||
: {},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOperationStatus(OperationStatus status) {
|
||||
Color color;
|
||||
switch (status) {
|
||||
case OperationStatus.canceled || OperationStatus.ko:
|
||||
color = Colors.grey.shade800;
|
||||
break;
|
||||
case OperationStatus.waitingforaction || OperationStatus.draft:
|
||||
color = Colors.orange;
|
||||
break;
|
||||
case OperationStatus.ok:
|
||||
color = Colors.green;
|
||||
break;
|
||||
case OperationStatus.waitingfordeployment ||
|
||||
OperationStatus.waitingforsupport:
|
||||
color = Colors.blue;
|
||||
break;
|
||||
}
|
||||
return Chip(
|
||||
label: Text("BOZZA", style: TextStyle(fontSize: 10, color: Colors.white)),
|
||||
backgroundColor: color,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}
|
||||
|
||||
void startNewOperation(BuildContext context) {
|
||||
context.pushNamed('operation-form');
|
||||
}
|
||||
}
|
||||
222
lib/features/operations/ui/widgets/customer_section.dart
Normal file
222
lib/features/operations/ui/widgets/customer_section.dart
Normal file
@@ -0,0 +1,222 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
423
lib/features/operations/ui/widgets/details_section.dart
Normal file
423
lib/features/operations/ui/widgets/details_section.dart
Normal file
@@ -0,0 +1,423 @@
|
||||
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/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';
|
||||
|
||||
class DetailsSection extends StatelessWidget {
|
||||
final OperationModel? currentOp;
|
||||
final String currentType;
|
||||
final TextEditingController freeTextSubtypeController;
|
||||
final TextEditingController freeTextDescriptionController;
|
||||
final Widget durationQuickPicks;
|
||||
|
||||
const DetailsSection({
|
||||
super.key,
|
||||
required this.currentOp,
|
||||
required this.currentType,
|
||||
required this.freeTextSubtypeController,
|
||||
required this.freeTextDescriptionController,
|
||||
required this.durationQuickPicks,
|
||||
});
|
||||
|
||||
bool _doesProviderMatchOperationType(dynamic provider, String operationType) {
|
||||
if (operationType == 'Custom') return true;
|
||||
switch (operationType) {
|
||||
case 'AL':
|
||||
case 'MNP':
|
||||
return provider.mobile == true;
|
||||
case 'NIP':
|
||||
return provider.landline == true;
|
||||
case 'UNICA':
|
||||
return provider.landline == true || provider.mobile == true;
|
||||
case 'Energy':
|
||||
return provider.energy == true;
|
||||
case 'Fin':
|
||||
return provider.financing == true;
|
||||
case 'Entertainment':
|
||||
return provider.entertainment == true;
|
||||
case 'TELEPASS':
|
||||
return provider.telepass == true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void _showProviderModal(BuildContext context, String operationType) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (modalContext) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.5,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.8,
|
||||
expand: false,
|
||||
builder: (_, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Seleziona Gestore',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.pop(modalContext),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: BlocBuilder<ProvidersCubit, ProvidersState>(
|
||||
builder: (context, state) {
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final allProviders = state.activeProviders;
|
||||
final filteredProviders = allProviders
|
||||
.where(
|
||||
(p) => _doesProviderMatchOperationType(
|
||||
p,
|
||||
operationType,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (filteredProviders.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Nessun gestore compatibile con questo servizio.',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: filteredProviders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final provider = filteredProviders[index];
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.business),
|
||||
title: Text(
|
||||
provider.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context
|
||||
.read<OperationsCubit>()
|
||||
.updateOperationFields(
|
||||
providerId: provider.id,
|
||||
providerDisplayName: provider.name,
|
||||
);
|
||||
Navigator.pop(modalContext);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// PROVIDER (Mostrato quasi sempre)
|
||||
ListTile(
|
||||
title: const Text('Seleziona Gestore'),
|
||||
subtitle: Text(
|
||||
(currentOp?.providerDisplayName != null &&
|
||||
currentOp!.providerDisplayName!.isNotEmpty)
|
||||
? currentOp!.providerDisplayName!
|
||||
: 'Nessun gestore selezionato',
|
||||
style: TextStyle(
|
||||
color:
|
||||
(currentOp?.providerId == null ||
|
||||
currentOp!.providerId!.isEmpty)
|
||||
? Colors.grey
|
||||
: null,
|
||||
fontWeight:
|
||||
(currentOp?.providerId == null ||
|
||||
currentOp!.providerId!.isEmpty)
|
||||
? FontWeight.normal
|
||||
: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_drop_down),
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: theme.dividerColor),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onTap: () => _showProviderModal(context, currentType),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 1. SCENARIO ENERGY (Dropdown Fisso)
|
||||
if (currentType == 'Energy') ...[
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue:
|
||||
(currentOp?.subtype != null && currentOp!.subtype!.isNotEmpty)
|
||||
? currentOp!.subtype
|
||||
: null,
|
||||
decoration: const InputDecoration(labelText: 'Dettaglio Fornitura'),
|
||||
items: [
|
||||
'Luce',
|
||||
'Gas',
|
||||
].map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
subtype: val,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: freeTextDescriptionController,
|
||||
decoration: InputDecoration(
|
||||
labelText: currentType == 'Energy'
|
||||
? 'Offerta scelta'
|
||||
: 'Nome del servizio/offerta',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 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),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// 3. SCENARIO ENTERTAINMENT O CUSTOM (Testo libero)
|
||||
if (currentType == 'Entertainment' || currentType == 'Custom') ...[
|
||||
TextFormField(
|
||||
controller: freeTextSubtypeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: currentType == 'Entertainment'
|
||||
? 'Piattaforma (es. Netflix, DAZN, Spotify...)'
|
||||
: 'Specifica il servizio (es. Monopattino)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// SCADENZA (Reattivo per tipi complessi)
|
||||
if ([
|
||||
'Energy',
|
||||
'Fin',
|
||||
'Entertainment',
|
||||
'Custom',
|
||||
].contains(currentType)) ...[
|
||||
const SizedBox(height: 8),
|
||||
durationQuickPicks, // Passiamo i chips dall'esterno
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
title: const Text('Data di Scadenza Effettiva'),
|
||||
subtitle: Text(
|
||||
currentOp?.expirationDate != null
|
||||
? "${currentOp!.expirationDate!.day}/${currentOp!.expirationDate!.month}/${currentOp!.expirationDate!.year}"
|
||||
: 'Nessuna scadenza impostata',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
trailing: const Icon(Icons.calendar_month, color: Colors.blue),
|
||||
tileColor: Colors.blue.withValues(alpha: 0.05),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Colors.blue, width: 0.5),
|
||||
),
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate:
|
||||
currentOp?.expirationDate ??
|
||||
DateTime.now().add(const Duration(days: 365)),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 3650)),
|
||||
);
|
||||
if (date != null && context.mounted) {
|
||||
context.read<OperationsCubit>().updateOperationFields(
|
||||
expirationDate: date,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
761
lib/features/operations/ui/widgets/operation_files_section.dart
Normal file
761
lib/features/operations/ui/widgets/operation_files_section.dart
Normal file
@@ -0,0 +1,761 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/features/operations/ui/widgets/staff_section.dart
Normal file
133
lib/features/operations/ui/widgets/staff_section.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
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(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user