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:
2026-05-04 15:36:42 +02:00
committed by brontomark
parent 9f57207a39
commit 94ad524bae
110 changed files with 5831 additions and 5306 deletions

View 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));
}
}
}

View 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 {}

View 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,
);
}
}

View 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,
);
}
}

View 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,
];
}

View 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';
}
}
}

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

View 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),
),
),
],
],
),
),
),
);
}
}

View 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),
),
);
}
}

View 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"!
}
}

View 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');
}
}

View 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);
},
);
},
);
},
),
),
],
);
},
);
},
);
}
}

View 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),
],
],
);
}
}

View 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,
),
),
),
),
],
);
},
),
],
);
},
);
}
}

View 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(),
),
);
},
),
],
);
}
}