feat-ultimi_servizi-contratti_in_scadenza #12

Merged
brontomark merged 18 commits from feat-ultimi_servizi-contratti_in_scadenza into main 2026-05-04 15:36:42 +02:00
32 changed files with 454 additions and 1031 deletions
Showing only changes of commit 1721b2ff89 - Show all commits

View File

@@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';

View File

@@ -4,8 +4,8 @@ import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
part 'customer_files_events.dart'; part 'customer_files_events.dart';
@@ -26,7 +26,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
LoadCustomerFilesEvent event, LoadCustomerFilesEvent event,
Emitter<CustomerFilesState> emit, Emitter<CustomerFilesState> emit,
) async { ) async {
await emit.forEach<List<CustomerFileModel>>( await emit.forEach<List<AttachmentModel>>(
_repository.getCustomerFilesStream(customerId), _repository.getCustomerFilesStream(customerId),
onData: (customerFiles) => CustomerFilesState( onData: (customerFiles) => CustomerFilesState(
status: CustomerFilesStatus.success, status: CustomerFilesStatus.success,
@@ -128,7 +128,7 @@ class CustomerFilesBloc extends Bloc<CustomerFilesEvent, CustomerFilesState> {
ToggleCustomerFileSelectionEvent event, ToggleCustomerFileSelectionEvent event,
Emitter<CustomerFilesState> emit, Emitter<CustomerFilesState> emit,
) { ) {
List<CustomerFileModel> selectedFiles = List.from(state.selectedFiles); List<AttachmentModel> selectedFiles = List.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) { if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file); selectedFiles.remove(event.file);
} else { } else {

View File

@@ -25,6 +25,6 @@ class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent {
class DeleteCustomerFilesEvent extends CustomerFilesEvent {} class DeleteCustomerFilesEvent extends CustomerFilesEvent {}
class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent { class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent {
final CustomerFileModel file; final AttachmentModel file;
const ToggleCustomerFileSelectionEvent(this.file); const ToggleCustomerFileSelectionEvent(this.file);
} }

View File

@@ -12,8 +12,8 @@ class CustomerFilesState extends Equatable {
final CustomerFilesStatus status; final CustomerFilesStatus status;
final String? error; final String? error;
final List<CustomerFileModel> customerFiles; final List<AttachmentModel> customerFiles;
final List<CustomerFileModel> selectedFiles; final List<AttachmentModel> selectedFiles;
@override @override
List<Object?> get props => [status, error, customerFiles, selectedFiles]; List<Object?> get props => [status, error, customerFiles, selectedFiles];
@@ -21,8 +21,8 @@ class CustomerFilesState extends Equatable {
CustomerFilesState copyWith({ CustomerFilesState copyWith({
CustomerFilesStatus? status, CustomerFilesStatus? status,
String? error, String? error,
List<CustomerFileModel>? customerFiles, List<AttachmentModel>? customerFiles,
List<CustomerFileModel>? selectedFiles, List<AttachmentModel>? selectedFiles,
}) { }) {
return CustomerFilesState( return CustomerFilesState(
status: status ?? this.status, status: status ?? this.status,

View File

@@ -14,14 +14,12 @@ class CustomerState extends Equatable {
final List<CustomerModel> customers; final List<CustomerModel> customers;
final CustomerModel? lastCreatedCustomer; final CustomerModel? lastCreatedCustomer;
final String? errorMessage; final String? errorMessage;
final List<CustomerFileModel> customerFiles;
const CustomerState({ const CustomerState({
this.status = CustomerStatus.initial, this.status = CustomerStatus.initial,
this.customers = const [], this.customers = const [],
this.lastCreatedCustomer, this.lastCreatedCustomer,
this.errorMessage, this.errorMessage,
this.customerFiles = const [],
}); });
CustomerState copyWith({ CustomerState copyWith({
@@ -29,14 +27,12 @@ class CustomerState extends Equatable {
List<CustomerModel>? customers, List<CustomerModel>? customers,
CustomerModel? lastCreatedCustomer, CustomerModel? lastCreatedCustomer,
String? errorMessage, String? errorMessage,
List<CustomerFileModel>? customerFiles,
}) { }) {
return CustomerState( return CustomerState(
status: status ?? this.status, status: status ?? this.status,
customers: customers ?? this.customers, customers: customers ?? this.customers,
lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer, lastCreatedCustomer: lastCreatedCustomer ?? this.lastCreatedCustomer,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
customerFiles: customerFiles ?? this.customerFiles,
); );
} }
@@ -46,6 +42,5 @@ class CustomerState extends Equatable {
customers, customers,
lastCreatedCustomer, lastCreatedCustomer,
errorMessage, errorMessage,
customerFiles,
]; ];
} }

View File

@@ -1,8 +1,7 @@
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/customers/models/customer_file_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
@@ -46,11 +45,11 @@ class CustomerRepository {
.from('customer') .from('customer')
.select(''' .select('''
*, *,
customer_file(*) attachment(*)
''') ''')
.eq('company_id', companyId) .eq('company_id', companyId)
.eq('is_active', true) .eq('is_active', true)
.order('nome'); .order('name');
return (response as List).map((c) => CustomerModel.fromMap(c)).toList(); return (response as List).map((c) => CustomerModel.fromMap(c)).toList();
} catch (e) { } catch (e) {
@@ -78,36 +77,34 @@ class CustomerRepository {
} }
/// Ascolta in tempo reale i file caricati per un cliente /// Ascolta in tempo reale i file caricati per un cliente
Stream<List<CustomerFileModel>> getCustomerFilesStream(String customerId) { Stream<List<AttachmentModel>> getCustomerFilesStream(String customerId) {
return _supabase return _supabase
.from('customer_file') .from('attachment')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('customer_id', customerId) .eq('customer_id', customerId)
.order('created_at', ascending: false) .order('created_at', ascending: false)
.map( .map(
(listOfMaps) => (listOfMaps) =>
listOfMaps.map((map) => CustomerFileModel.fromMap(map)).toList(), listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(),
); );
} }
/// Recupera i file di un cliente specifico /// Recupera i file di un cliente specifico
Future<List<CustomerFileModel>> getCustomerFiles(String customerId) async { Future<List<AttachmentModel>> getCustomerFiles(String customerId) async {
try { try {
final response = await _supabase final response = await _supabase
.from('customer_file') .from('attachment')
.select() .select()
.eq('customer_id', customerId); .eq('customer_id', customerId);
return (response as List) return (response as List).map((f) => AttachmentModel.fromMap(f)).toList();
.map((f) => CustomerFileModel.fromMap(f))
.toList();
} catch (e) { } catch (e) {
throw '$e'; throw '$e';
} }
} }
/// Carica un file e salva il riferimento nel database /// Carica un file e salva il riferimento nel database
Future<CustomerFileModel> uploadAndRegisterFile({ Future<AttachmentModel> uploadAndRegisterFile({
required String customerId, required String customerId,
required PlatformFile pickedFile, required PlatformFile pickedFile,
}) async { }) async {
@@ -118,7 +115,8 @@ class CustomerRepository {
final storagePath = final storagePath =
'$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName'; '$companyId/customers/${DateTime.now().millisecondsSinceEpoch}_$cleanFileName';
final int fileSize = pickedFile.size; final int fileSize = pickedFile.size;
final fileToSave = CustomerFileModel( final fileToSave = AttachmentModel(
companyId: companyId,
customerId: customerId, customerId: customerId,
name: cleanFileName.fileNameWithoutExtension(), name: cleanFileName.fileNameWithoutExtension(),
extension: cleanFileName.fileExtension(), extension: cleanFileName.fileExtension(),
@@ -146,46 +144,47 @@ class CustomerRepository {
} }
final response = await _supabase final response = await _supabase
.from('customer_file') .from('attachment')
.insert(fileToSave.toMap()) .insert(fileToSave.toMap())
.select() .select()
.single(); .single();
return CustomerFileModel.fromMap(response); return AttachmentModel.fromMap(response);
} catch (e) { } catch (e) {
throw '$e'; throw '$e';
} }
} }
Future<void> saveFileReference(CustomerFileModel file) async { Future<void> saveFileReference(AttachmentModel file) async {
await _supabase.from('customer_file').upsert(file.toMap()); await _supabase.from('attachment').upsert(file.toMap());
} }
/// Aggiorna la lista degli URL nel database Future<void> deleteDocuments(List<AttachmentModel> files) async {
Future<void> updateCustomerDocuments(int id, List<String> urls) async {
await _supabase
.from('customer')
.update({'document_urls': urls})
.eq('id', id);
}
Future<void> deleteDocuments(List<CustomerFileModel> files) async {
if (files.isEmpty) return; if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi // 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = files.map((f) => f.id!).toList(); final List<String> idsToDelete = [];
final List<String> storagePaths = files.map((f) => f.storagePath).toList(); final List<String> storagePathsToDelete = [];
final List<String> idsToEdit = [];
for (var file in files) {
if (file.operationId == null) {
idsToDelete.add(file.id!);
storagePathsToDelete.add(file.storagePath);
} else {
idsToEdit.add(file.id!);
}
}
try { try {
// 2. Cancellazione MASSIVA dal DB (una sola chiamata invece di un ciclo!) if (idsToDelete.isNotEmpty) {
// .in_ dice: "cancella tutti i record il cui ID è contenuto in questa lista" await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
await _supabase
.from('customer_file')
.delete()
.inFilter('id', idsToDelete);
// 3. Cancellazione MASSIVA dallo Storage // 3. Cancellazione MASSIVA dallo Storage
await _supabase.storage.from('documents').remove(storagePaths); await _supabase.storage.from('documents').remove(storagePathsToDelete);
}
if (idsToEdit.isNotEmpty) {
await _supabase
.from('attachment')
.update({'customer_id': null})
.inFilter('id', idsToEdit);
}
} on PostgrestException catch (e) { } on PostgrestException catch (e) {
throw e.message; throw e.message;
} catch (e) { } catch (e) {

View File

@@ -1,91 +0,0 @@
import 'package:equatable/equatable.dart';
class CustomerFileModel extends Equatable {
final String? id;
final String customerId; // Riferimento UUID
final String name;
final String storagePath;
final String extension;
final DateTime? createdAt;
final int fileSize;
const CustomerFileModel({
this.id,
required this.customerId,
required this.name,
required this.storagePath,
required this.extension,
this.createdAt,
required this.fileSize,
});
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
CustomerFileModel copyWith({
String? id,
String? customerId,
String? name,
String? storagePath,
String? extension,
DateTime? createdAt,
int? fileSize,
}) {
return CustomerFileModel(
id: id ?? this.id,
customerId: customerId ?? this.customerId,
name: name ?? this.name,
storagePath: storagePath ?? this.storagePath,
extension: extension ?? this.extension,
createdAt: createdAt ?? this.createdAt,
fileSize: fileSize ?? this.fileSize,
);
}
factory CustomerFileModel.fromMap(Map<String, dynamic> map) {
return CustomerFileModel(
id: map['id'] as String,
customerId: map['customer_id'],
name: map['name'],
storagePath: map['storage_path'],
extension: map['extension'] ?? '',
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'customer_id': customerId,
'name': name,
'storage_path': storagePath,
'extension': extension,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
customerId,
name,
storagePath,
extension,
createdAt,
fileSize,
];
}

View File

@@ -88,6 +88,11 @@ class CustomerModel extends Equatable {
doNotDisturb: map['do_not_disturb'] ?? false, doNotDisturb: map['do_not_disturb'] ?? false,
companyId: map['company_id'] as String, companyId: map['company_id'] as String,
isActive: map['is_active'] ?? true, isActive: map['is_active'] ?? true,
attachments:
(map['attachment'] as List?)
?.map((x) => AttachmentModel.fromMap(x))
.toList() ??
const [],
); );
} }

View File

@@ -6,9 +6,9 @@ import 'package:flux/core/theme/theme.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/customers/blocs/customer_files_bloc.dart';
import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/models/customer_model.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
class CustomerDetailScreen extends StatefulWidget { class CustomerDetailScreen extends StatefulWidget {
final CustomerModel customer; final CustomerModel customer;
@@ -262,12 +262,12 @@ class _CustomerDetailScreenState extends State<CustomerDetailScreen> {
void _showDeleteConfirmationDialog({ void _showDeleteConfirmationDialog({
required BuildContext context, required BuildContext context,
required List<CustomerFileModel> files, required List<AttachmentModel> files,
}) {} }) {}
} }
class _FileCard extends StatelessWidget { class _FileCard extends StatelessWidget {
final CustomerFileModel file; final AttachmentModel file;
final CustomerFilesState state; final CustomerFilesState state;
const _FileCard({required this.file, required this.state}); const _FileCard({required this.file, required this.state});
@@ -334,7 +334,7 @@ class _FileCard extends StatelessWidget {
} }
} }
void _handleDoubleClickOnFile(BuildContext context, CustomerFileModel file) { void _handleDoubleClickOnFile(BuildContext context, AttachmentModel file) {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,

View File

@@ -196,11 +196,11 @@ class _CustomerTile extends StatelessWidget {
style: TextStyle(color: context.secondaryText), style: TextStyle(color: context.secondaryText),
), ),
], ],
if (customer.files.isNotEmpty) ...[ if (customer.attachments.isNotEmpty) ...[
Text(' - ', style: TextStyle(color: context.secondaryText)), Text(' - ', style: TextStyle(color: context.secondaryText)),
Icon(Icons.attach_file, size: 14, color: context.accent), Icon(Icons.attach_file, size: 14, color: context.accent),
Text( Text(
'${customer.files.length} doc', '${customer.attachments.length} doc',
style: TextStyle( style: TextStyle(
color: context.accent, color: context.accent,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@@ -156,7 +156,7 @@ class _LatestOperationsCardContent extends StatelessWidget {
Expanded( Expanded(
flex: 5, flex: 5,
child: Text( child: Text(
operation.number, operation.reference,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: context.primaryText, color: context.primaryText,

View File

@@ -1,14 +1,13 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.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/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/operations/data/operations_repository.dart'; import 'package:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/operation_file_model.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:image_picker/image_picker.dart';
part 'operation_files_events.dart'; part 'operation_files_events.dart';
part 'operation_files_state.dart'; part 'operation_files_state.dart';
@@ -29,9 +28,10 @@ class OperationFilesBloc
on<LoadOperationFilesEvent>(_onLoadOperationFiles); on<LoadOperationFilesEvent>(_onLoadOperationFiles);
on<AddOperationFilesEvent>(_onAddOperationFiles); on<AddOperationFilesEvent>(_onAddOperationFiles);
on<UploadOperationFilesEvent>(_onUploadOperationFiles); on<UploadOperationFilesEvent>(_onUploadOperationFiles);
on<UploadMultipleOperationFilesEvent>(_onUploadMultipleOperationFiles);
on<DeleteOperationFilesEvent>(_onDeleteOperationFiles); on<DeleteOperationFilesEvent>(_onDeleteOperationFiles);
on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection); on<ToggleOperationFileSelectionEvent>(_onToggleOperationFileSelection);
on<LinkFilesToCustomerEvent>(_onLinkFilesToCustomer);
// Se il BLoC nasce con un ID, accendiamo subito lo stream! // Se il BLoC nasce con un ID, accendiamo subito lo stream!
if (operationId != null) { if (operationId != null) {
add(LoadOperationFilesEvent(operationId: operationId)); add(LoadOperationFilesEvent(operationId: operationId));
@@ -41,18 +41,53 @@ class OperationFilesBloc
FutureOr<void> _onOperationsaved( FutureOr<void> _onOperationsaved(
OperationsavedEvent event, OperationsavedEvent event,
Emitter<OperationFilesState> emit, Emitter<OperationFilesState> emit,
) { ) async {
// 1. Aggiorniamo l'ID nello stato // 1. Aggiorniamo l'ID e mettiamo in loading
// 2. PIALLIAMO i file locali: ormai sono partiti per Supabase!
// Così la UI si pulisce all'istante e aspetta quelli remoti.
emit( emit(
state.copyWith( state.copyWith(
operationId: event.operationId, operationId: event.operationId,
localFiles: [], // <-- LA MAGIA ANTI-DUPLICATI status: OperationFilesStatus.uploading,
), ),
); );
// Lanciamo il caricamento // 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)); add(LoadOperationFilesEvent(operationId: event.operationId));
} }
@@ -60,17 +95,14 @@ class OperationFilesBloc
LoadOperationFilesEvent event, LoadOperationFilesEvent event,
Emitter<OperationFilesState> emit, Emitter<OperationFilesState> emit,
) async { ) async {
// Usiamo l'ID dell'evento, e se non c'è usiamo quello dello stato
final currentId = event.operationId ?? state.operationId; final currentId = event.operationId ?? state.operationId;
if (currentId != null) { if (currentId != null) {
emit(state.copyWith(status: OperationFilesStatus.loading)); emit(state.copyWith(status: OperationFilesStatus.loading));
await emit.forEach( await emit.forEach(
_repository.getOperationFilesStream( _repository.getOperationFilesStream(currentId),
currentId, onData: (List<AttachmentModel> data) => state.copyWith(
), // <-- Usiamo l'ID corretto!
onData: (data) => state.copyWith(
status: OperationFilesStatus.success, status: OperationFilesStatus.success,
remoteFiles: data, remoteFiles: data,
), ),
@@ -87,13 +119,15 @@ class OperationFilesBloc
Emitter<OperationFilesState> emit, Emitter<OperationFilesState> emit,
) async { ) async {
final currentId = state.operationId; final currentId = state.operationId;
// BIVIO 1: PRATICA NUOVA (Nessun ID)
// BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale)
if (currentId == null) { if (currentId == null) {
// Mettiamo i file nel "parcheggio" locale dello State final companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
final newLocalFiles = event.files.map((file) { final newLocalFiles = event.files.map((file) {
return OperationFileModel( return AttachmentModel(
id: null, id: null,
operationId: operationId ?? '', companyId: companyId,
operationId: '', // Sarà riempito al salvataggio
name: file.name.fileNameWithoutExtension(), name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(), extension: file.name.fileExtension(),
storagePath: '', storagePath: '',
@@ -101,29 +135,29 @@ class OperationFilesBloc
localBytes: file.bytes, localBytes: file.bytes,
); );
}).toList(); }).toList();
final List<OperationFileModel> updatedLocalFiles = [
...state.localFiles,
...newLocalFiles,
];
emit( emit(
state.copyWith( state.copyWith(
localFiles: updatedLocalFiles, localFiles: [...state.localFiles, ...newLocalFiles],
status: OperationFilesStatus.success, status: OperationFilesStatus.success,
), ),
); );
return; return;
} }
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID) // BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID - Upload immediato)
emit(state.copyWith(status: OperationFilesStatus.uploading)); emit(state.copyWith(status: OperationFilesStatus.uploading));
try { try {
// Logica identica a quella che abbiamo fatto per i clienti final List<Future<void>> uploadTasks = [];
for (var file in event.files) { for (var file in event.files) {
await _repository.uploadAndRegisterOperationFile( uploadTasks.add(
operationId: operationId!, _repository.uploadAndRegisterOperationFile(
operationId: currentId,
pickedFile: file, pickedFile: file,
),
); );
} }
await Future.wait(uploadTasks);
emit(state.copyWith(status: OperationFilesStatus.success)); emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) { } catch (e) {
emit( emit(
@@ -139,51 +173,20 @@ class OperationFilesBloc
UploadOperationFilesEvent event, UploadOperationFilesEvent event,
Emitter<OperationFilesState> emit, Emitter<OperationFilesState> emit,
) async { ) async {
if (event.pickedFiles == null && event.photos == null) return; if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) &&
if (event.pickedFiles!.isEmpty && event.photos!.isEmpty) return; (event.photos == null || event.photos!.isEmpty)) {
// BIVIO 2: PRATICA ESISTENTE (Abbiamo l'ID
emit(state.copyWith(status: OperationFilesStatus.uploading));
try {
// Logica identica a quella che abbiamo fatto per i clienti
if (event.pickedFiles != null && event.pickedFiles!.isNotEmpty) {
for (var file in event.pickedFiles!) {
await _repository.uploadAndRegisterOperationFile(
operationId: state.operationId!,
pickedFile: file,
);
}
}
emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: e.toString(),
),
);
}
}
FutureOr<void> _onUploadMultipleOperationFiles(
UploadMultipleOperationFilesEvent event,
Emitter<OperationFilesState> emit,
) async {
if (event.files.isEmpty) {
emit(
state.copyWith(
status: OperationFilesStatus.failure,
error: "Nessun file selezionato",
),
);
return; return;
} }
emit(state.copyWith(status: OperationFilesStatus.uploading, error: null));
if (state.operationId == null) return;
emit(state.copyWith(status: OperationFilesStatus.uploading));
try { try {
// 2. Creiamo una lista di "Promesse" (Futures) per il repository
final List<Future<void>> uploadTasks = []; final List<Future<void>> uploadTasks = [];
for (var file in event.files) {
// Aggiungiamo il task alla lista, ma NON usiamo await qui dentro! // 1. Gestione Documenti normali (PlatformFile)
if (event.pickedFiles != null) {
for (var file in event.pickedFiles!) {
uploadTasks.add( uploadTasks.add(
_repository.uploadAndRegisterOperationFile( _repository.uploadAndRegisterOperationFile(
operationId: state.operationId!, operationId: state.operationId!,
@@ -191,17 +194,40 @@ class OperationFilesBloc
), ),
); );
} }
// 3. ESECUZIONE PARALLELA! }
// Aspettiamo che tutti i file siano caricati contemporaneamente.
// 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); await Future.wait(uploadTasks);
// 4. GRAN FINALE: Tutto caricato, emettiamo il success!
emit(state.copyWith(status: OperationFilesStatus.success)); emit(state.copyWith(status: OperationFilesStatus.success));
} catch (e) { } catch (e) {
// Se anche un solo file fallisce, catturiamo l'errore
emit( emit(
state.copyWith( state.copyWith(
status: OperationFilesStatus.failure, status: OperationFilesStatus.failure,
error: "Errore durante l'upload multiplo: $e", error: e.toString(),
), ),
); );
} }
@@ -231,7 +257,7 @@ class OperationFilesBloc
ToggleOperationFileSelectionEvent event, ToggleOperationFileSelectionEvent event,
Emitter<OperationFilesState> emit, Emitter<OperationFilesState> emit,
) { ) {
List<OperationFileModel> selectedFiles = List.from(state.selectedFiles); final selectedFiles = List<AttachmentModel>.from(state.selectedFiles);
if (selectedFiles.contains(event.file)) { if (selectedFiles.contains(event.file)) {
selectedFiles.remove(event.file); selectedFiles.remove(event.file);
} else { } else {
@@ -239,4 +265,62 @@ class OperationFilesBloc
} }
emit(state.copyWith(selectedFiles: selectedFiles)); emit(state.copyWith(selectedFiles: 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",
),
);
}
}
} }

View File

@@ -17,7 +17,7 @@ class OperationsavedEvent extends OperationFilesEvent {
class LoadOperationFilesEvent extends OperationFilesEvent { class LoadOperationFilesEvent extends OperationFilesEvent {
final String? operationId; final String? operationId;
final OperationModel? operation; final AttachmentModel? operation;
const LoadOperationFilesEvent({this.operationId, this.operation}); const LoadOperationFilesEvent({this.operationId, this.operation});
@override @override
@@ -34,23 +34,25 @@ class AddOperationFilesEvent extends OperationFilesEvent {
class UploadOperationFilesEvent extends OperationFilesEvent { class UploadOperationFilesEvent extends OperationFilesEvent {
final List<PlatformFile>? pickedFiles; final List<PlatformFile>? pickedFiles;
final List<File>? photos; final List<XFile>? photos;
const UploadOperationFilesEvent({this.pickedFiles, this.photos}); const UploadOperationFilesEvent({this.pickedFiles, this.photos});
@override @override
List<Object?> get props => [pickedFiles, photos]; List<Object?> get props => [pickedFiles, photos];
} }
class UploadMultipleOperationFilesEvent extends OperationFilesEvent { class LinkFilesToCustomerEvent extends OperationFilesEvent {
final List<PlatformFile> files; final String customerId;
const UploadMultipleOperationFilesEvent(this.files);
const LinkFilesToCustomerEvent({required this.customerId});
@override @override
List<Object?> get props => [files]; List<Object?> get props => [customerId];
} }
class DeleteOperationFilesEvent extends OperationFilesEvent {} class DeleteOperationFilesEvent extends OperationFilesEvent {}
class ToggleOperationFileSelectionEvent extends OperationFilesEvent { class ToggleOperationFileSelectionEvent extends OperationFilesEvent {
final OperationFileModel file; final AttachmentModel file;
const ToggleOperationFileSelectionEvent(this.file); const ToggleOperationFileSelectionEvent(this.file);
} }

View File

@@ -15,10 +15,10 @@ class OperationFilesState extends Equatable {
final String? operationId; final String? operationId;
final OperationFilesStatus status; final OperationFilesStatus status;
final String? error; final String? error;
final List<OperationFileModel> localFiles; final List<AttachmentModel> localFiles;
final List<OperationFileModel> remoteFiles; final List<AttachmentModel> remoteFiles;
final List<OperationFileModel> selectedFiles; final List<AttachmentModel> selectedFiles;
@override @override
List<Object?> get props => [ List<Object?> get props => [
@@ -30,15 +30,15 @@ class OperationFilesState extends Equatable {
selectedFiles, selectedFiles,
]; ];
List<OperationFileModel> get allFiles => [...remoteFiles, ...localFiles]; List<AttachmentModel> get allFiles => [...remoteFiles, ...localFiles];
OperationFilesState copyWith({ OperationFilesState copyWith({
String? operationId, String? operationId,
OperationFilesStatus? status, OperationFilesStatus? status,
String? error, String? error,
List<OperationFileModel>? localFiles, List<AttachmentModel>? localFiles,
List<OperationFileModel>? remoteFiles, List<AttachmentModel>? remoteFiles,
List<OperationFileModel>? selectedFiles, List<AttachmentModel>? selectedFiles,
}) { }) {
return OperationFilesState( return OperationFilesState(
operationId: operationId ?? this.operationId, operationId: operationId ?? this.operationId,

View File

@@ -4,19 +4,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.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:flux/features/operations/data/operations_repository.dart';
import 'package:flux/features/operations/models/energy_operation_model.dart';
import 'package:flux/features/operations/models/entertainment_operation_model.dart';
import 'package:flux/features/operations/models/fin_operation_model.dart';
import 'package:flux/features/operations/models/operation_file_model.dart';
import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/models/operation_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:uuid/uuid.dart';
part 'operations_state.dart'; part 'operations_state.dart';
class OperationsCubit extends Cubit<OperationsState> { class OperationsCubit extends Cubit<OperationsState> {
final OperationsRepository _repository = GetIt.I<OperationsRepository>(); final OperationsRepository _repository = GetIt.I<OperationsRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>(); final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
final Uuid _uuid = const Uuid(); // Generatore di UUID per il batch
OperationsCubit() OperationsCubit()
: super(const OperationsState(status: OperationsStatus.initial)); : super(const OperationsState(status: OperationsStatus.initial));
@@ -24,17 +23,13 @@ class OperationsCubit extends Cubit<OperationsState> {
// --- CARICAMENTO E PAGINAZIONE --- // --- CARICAMENTO E PAGINAZIONE ---
Future<void> loadOperations({bool refresh = false}) async { Future<void> loadOperations({bool refresh = false}) async {
// Se stiamo già caricando, evitiamo chiamate doppie
if (state.status == OperationsStatus.loading) return; if (state.status == OperationsStatus.loading) return;
// Se non è un refresh e abbiamo già raggiunto la fine dei dati, ci fermiamo
if (!refresh && state.hasReachedMax) return; if (!refresh && state.hasReachedMax) return;
emit( emit(
state.copyWith( state.copyWith(
status: OperationsStatus.loading, status: OperationsStatus.loading,
errorMessage: null, errorMessage: null,
// Se è un refresh, svuotiamo la lista attuale per mostrare lo shimmer/loading
allOperations: refresh ? [] : state.allOperations, allOperations: refresh ? [] : state.allOperations,
hasReachedMax: refresh ? false : state.hasReachedMax, hasReachedMax: refresh ? false : state.hasReachedMax,
), ),
@@ -56,7 +51,6 @@ class OperationsCubit extends Cubit<OperationsState> {
dateRange: state.dateRange, dateRange: state.dateRange,
); );
// Se ricevi meno record del limite, significa che non ce ne sono altri sul DB
final bool reachedMax = newOperations.length < 50; final bool reachedMax = newOperations.length < 50;
emit( emit(
@@ -72,7 +66,7 @@ class OperationsCubit extends Cubit<OperationsState> {
emit( emit(
state.copyWith( state.copyWith(
status: OperationsStatus.failure, status: OperationsStatus.failure,
errorMessage: "Errore nel caricamento servizi: $e", errorMessage: "Errore nel caricamento operazioni: $e",
), ),
); );
} }
@@ -80,7 +74,6 @@ class OperationsCubit extends Cubit<OperationsState> {
// --- GESTIONE FILTRI --- // --- GESTIONE FILTRI ---
/// Aggiorna i parametri di ricerca e ricarica da zero
void updateFilters({String? query, DateTimeRange? range}) { void updateFilters({String? query, DateTimeRange? range}) {
emit( emit(
state.copyWith( state.copyWith(
@@ -91,15 +84,11 @@ class OperationsCubit extends Cubit<OperationsState> {
loadOperations(refresh: true); loadOperations(refresh: true);
} }
/// Pulisce tutti i filtri
void clearFilters() { void clearFilters() {
emit(state.copyWith(query: '', dateRange: null)); emit(state.copyWith(query: '', dateRange: null));
loadOperations(refresh: true); loadOperations(refresh: true);
} }
// --- GESTIONE BOZZA (DRAFT) ---
/// Inizializza un nuovo servizio o ne carica uno esistente per la modifica
void initOperationForm({ void initOperationForm({
OperationModel? existingOperation, OperationModel? existingOperation,
String? operationId, String? operationId,
@@ -123,14 +112,16 @@ class OperationsCubit extends Cubit<OperationsState> {
), ),
); );
} else { } else {
// Crea un template vuoto con lo store di default (se disponibile) // NUOVA PRATICA: Creiamo un nuovo Batch UUID
emit( emit(
state.copyWith( state.copyWith(
currentOperation: OperationModel( currentOperation: OperationModel(
storeId: _sessionCubit.state.currentStore?.id ?? '', storeId: _sessionCubit.state.currentStore?.id ?? '',
number: '', // Sarà compilato dall'utente reference: '',
createdAt: DateTime.now(), createdAt: DateTime.now(),
companyId: _sessionCubit.state.company!.id!, companyId: _sessionCubit.state.company!.id!,
status: OperationStatus.draft,
batchUuid: _uuid.v4(), // <-- GENERIAMO IL BATCH UNIVOCO
), ),
status: OperationsStatus.ready, status: OperationsStatus.ready,
), ),
@@ -138,68 +129,25 @@ class OperationsCubit extends Cubit<OperationsState> {
} }
} }
/// Metodo generico per aggiornare i campi base (AL, MNP, Note, ecc.) /// MAGIA PURA: Prepara il form per inserire un altro servizio nella stessa pratica.
void updateField({ /// Mantiene il Cliente, il Batch e lo Store, ma svuota il resto.
int? al, void prepareNextOperationInBatch() {
int? mnp,
int? nip,
int? unica,
int? telepass,
String? note,
String? number,
bool? isBozza,
bool? resultOk,
String? customerId,
String? customerDisplayName,
}) {
if (state.currentOperation == null) return; if (state.currentOperation == null) return;
final updated = state.currentOperation!.copyWith( final current = state.currentOperation!;
al: al,
mnp: mnp,
nip: nip,
unica: unica,
telepass: telepass,
note: note,
number: number,
isBozza: isBozza,
resultOk: resultOk,
customerId: customerId,
customerDisplayName: customerDisplayName,
);
emit(state.copyWith(currentOperation: updated));
}
// --- GESTIONE MODULI COMPLESSI ---
void updateEnergyOperations(List<EnergyOperationModel> energyList) {
emit( emit(
state.copyWith( state.copyWith(
currentOperation: state.currentOperation?.copyWith( status: OperationsStatus.ready,
energyOperations: energyList, currentOperation: OperationModel(
), companyId: current.companyId,
), storeId: current.storeId,
); storeDisplayName: current.storeDisplayName,
} batchUuid: current.batchUuid, // <-- MANTIENE IL COLLEGAMENTO
customerId: current.customerId, // <-- MANTIENE IL CLIENTE
void updateFinOperations(List<FinOperationModel> finList) { customerDisplayName: current.customerDisplayName,
emit( status: OperationStatus.draft,
state.copyWith( createdAt: DateTime.now(),
currentOperation: state.currentOperation?.copyWith(
finOperations: finList,
),
),
);
}
void updateEntertainmentOperations(
List<EntertainmentOperationModel> entList,
) {
emit(
state.copyWith(
currentOperation: state.currentOperation?.copyWith(
entertainmentOperations: entList,
), ),
), ),
); );
@@ -208,35 +156,33 @@ class OperationsCubit extends Cubit<OperationsState> {
// --- PERSISTENZA --- // --- PERSISTENZA ---
Future<void> saveCurrentOperation({ Future<void> saveCurrentOperation({
required bool isBozza, required OperationStatus targetStatus,
bool shouldPop = true, bool shouldPop = true,
List<OperationFileModel>? files,
}) async { }) async {
if (state.currentOperation == null) return; if (state.currentOperation == null) return;
emit(state.copyWith(status: OperationsStatus.saving, errorMessage: null)); emit(state.copyWith(status: OperationsStatus.saving, errorMessage: null));
try { try {
// 1. Aggiorniamo il flag bozza in base a quale pulsante ha premuto l'utente
final operationToSave = state.currentOperation!.copyWith( final operationToSave = state.currentOperation!.copyWith(
isBozza: isBozza, status: targetStatus,
files: files,
); );
// 2. Salvataggio corazzato
final updatedOperation = await _repository.saveFullOperation( final updatedOperation = await _repository.saveFullOperation(
operationToSave, operationToSave,
); );
// 3. Reset e ricaricamento
emit( emit(
state.copyWith( state.copyWith(
// Se non facciamo Pop (es. l'utente vuole aggiungere un altro servizio), non killiamo l'operazione corrente
status: shouldPop status: shouldPop
? OperationsStatus.saved ? OperationsStatus.saved
: OperationsStatus.savedNoPop, : OperationsStatus.savedNoPop,
currentOperation: shouldPop ? null : updatedOperation, currentOperation: shouldPop ? null : updatedOperation,
), ),
); );
await loadOperations(refresh: true);
// Ricarica in background per la dashboard
loadOperations(refresh: true);
} catch (e) { } catch (e) {
emit( emit(
state.copyWith( state.copyWith(
@@ -247,115 +193,29 @@ class OperationsCubit extends Cubit<OperationsState> {
} }
} }
// --- GESTIONE ALLEGATI LOCALI --- // --- RECUPERO OPERAZIONI DELLO STESSO BATCH (Per UI di riepilogo) ---
void addAttachments(List<PlatformFile> files) { /// Puoi usare questa funzione se nella UI vuoi mostrare "Hai inserito 3 servizi in questa pratica"
final newAttachments = files.map((file) { List<OperationModel> getOperationsInCurrentBatch() {
return OperationFileModel( if (state.currentOperation == null) return [];
id: null, // Meglio null se non è su DB final currentBatch = state.currentOperation!.batchUuid;
operationId: state.currentOperation?.id ?? '',
name: file.name.fileNameWithoutExtension(),
extension: file.name.fileExtension(),
storagePath: '',
fileSize: file.size,
localBytes: file.bytes,
createdAt: DateTime.now(),
);
}).toList();
// Creiamo una nuova lista pulita // Filtriamo dalla lista caricata (o potresti fare una query diretta a Supabase se preferisci)
final List<OperationFileModel> updatedList = [ return state.allOperations
...(state.currentOperation?.files ?? []), .where(
...newAttachments, (op) =>
]; op.batchUuid == currentBatch &&
op.id != state.currentOperation!.id,
// Emettiamo lo stato assicurandoci che il OperationModel venga clonato )
if (state.currentOperation != null) { .toList();
emit(
state.copyWith(
currentOperation: state.currentOperation!.copyWith(
files: updatedList,
),
),
);
}
} }
void removeAttachment(int index) { void updateField({String? customerId, String? customerDisplayName}) {
if (state.currentOperation == null) return; if (state.currentOperation == null) return;
final updated = state.currentOperation!.copyWith(
final updatedList = List<OperationFileModel>.from( customerId: customerId,
state.currentOperation!.files, customerDisplayName: customerDisplayName,
); );
updatedList.removeAt(index); emit(state.copyWith(currentOperation: updated));
emit(
state.copyWith(
currentOperation: state.currentOperation?.copyWith(files: updatedList),
),
);
}
void saveAndCopyFileToCustomer(List<OperationFileModel> selectedFiles) async {
final currentOperation = state.currentOperation;
// 1. Check di sicurezza: se non c'è il cliente, non sappiamo dove copiare
if (currentOperation == null || currentOperation.customerId == null) {
emit(
state.copyWith(
status: OperationsStatus.failure,
errorMessage:
"Impossibile copiare: nessun cliente associato alla pratica.",
),
);
return;
}
emit(state.copyWith(status: OperationsStatus.loading));
try {
// 2. SALVATAGGIO CORAZZATO
// Chiamiamo il repo e otteniamo la pratica con TUTTI i file ora dotati di ID e storagePath
final updatedOperation = await _repository.saveFullOperation(
currentOperation,
);
// 3. COPIA RELAZIONALE
// Per ogni file che l'utente ha selezionato nella UI, cerchiamo la sua versione
// "ufficiale" (quella con lo storagePath) nel modello appena tornato dal DB.
for (var selectedFile in selectedFiles) {
// Cerchiamo il match nel modello aggiornato
final persistedFile = updatedOperation.files.firstWhere(
(f) =>
f.name == selectedFile.name &&
f.extension == selectedFile.extension,
orElse: () => throw Exception(
"File ${selectedFile.name} non trovato dopo il salvataggio.",
),
);
// Creiamo il link nel database del cliente
await _repository.copyFileToCustomer(
file: persistedFile,
customerId: currentOperation.customerId!,
);
}
// 4. AGGIORNAMENTO STATO
// Aggiorniamo il Cubit con il servizio salvato così la UI mostra i file come "Remoti"
emit(
state.copyWith(
status: OperationsStatus.success,
currentOperation: updatedOperation,
),
);
} catch (e) {
emit(
state.copyWith(
status: OperationsStatus.failure,
errorMessage: "Errore durante il salvataggio e copia: $e",
),
);
}
} }
} }

View File

@@ -3,9 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/customers/data/customer_repository.dart';
import 'package:flux/features/customers/models/customer_file_model.dart';
import 'package:flux/features/operations/models/operation_file_model.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/operation_model.dart'; import '../models/operation_model.dart';
@@ -13,7 +10,6 @@ import '../models/operation_model.dart';
class OperationsRepository { class OperationsRepository {
final _supabase = Supabase.instance.client; final _supabase = Supabase.instance.client;
final companyId = GetIt.I.get<SessionCubit>().state.company!.id; final companyId = GetIt.I.get<SessionCubit>().state.company!.id;
final CustomerRepository _customerRepository = GetIt.I<CustomerRepository>();
// --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO --- // --- RECUPERO SINGOLO SERVIZIO CON JOIN COMPLETO ---
Future<OperationModel> fetchOperationById(String id) async { Future<OperationModel> fetchOperationById(String id) async {
@@ -23,7 +19,11 @@ class OperationsRepository {
.select(''' .select('''
*, *,
customer(name), customer(name),
staff_member(name) store(name),
staff_member(name),
provider(name),
model(name_with_brand),
attachments(*)
''') ''')
.eq('id', id) .eq('id', id)
.single(); .single();
@@ -48,6 +48,9 @@ class OperationsRepository {
.select(''' .select('''
*, *,
customer(name), customer(name),
store(name),
provider(name),
model(name_with_brand),
staff_member(name), staff_member(name),
attachments(*) attachments(*)
''') ''')
@@ -107,49 +110,6 @@ class OperationsRepository {
final String newId = operationData['id']; final String newId = operationData['id'];
// 4. UPLOAD DEI FILE LOCALI (Nuovi)
// Filtriamo solo i file che non hanno ancora un ID (quindi sono locali)
final localFilesToUpload = operation.attachments
.where((f) => f.id == null)
.toList();
if (localFilesToUpload.isNotEmpty) {
final List<Future> uploadTasks = [];
for (var file in localFilesToUpload) {
final storagePath =
'$companyId/operations/$newId/${DateTime.now().millisecondsSinceEpoch}_${file.name}.${file.extension}';
final String mimeType = file.extension.toLowerCase() == 'pdf'
? 'application/pdf'
: 'image/${file.extension}';
final fileToSave = file.copyWith(
operationId: newId,
storagePath: storagePath,
);
// Creiamo una funzione asincrona per caricare file e scrivere nel DB
Future<void> uploadAndLink() async {
// A. Upload nel Bucket Storage (usiamo i bytes così funziona anche su Web!)
await _supabase.storage
.from('documents')
.uploadBinary(
storagePath,
fileToSave.localBytes!,
fileOptions: FileOptions(contentType: mimeType, upsert: true),
);
// B. Inserimento riga nel DB relazionale
await _supabase.from('attachment').insert(fileToSave.toMap());
}
uploadTasks.add(uploadAndLink());
}
// Eseguiamo tutti gli upload in parallelo per la massima velocità
await Future.wait(uploadTasks);
}
// 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO // 5. GRAN FINALE: RECUPERO DEL MODELLO COMPLETO E AGGIORNATO
// Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati // Interroghiamo Supabase per farci restituire la pratica con TUTTI gli ID generati
// (inclusi quelli della tabella operation_file appena inseriti) // (inclusi quelli della tabella operation_file appena inseriti)
@@ -158,6 +118,9 @@ class OperationsRepository {
.select(''' .select('''
*, *,
staff_member(name), staff_member(name),
store(name),
provider(name),
model(name_with_brand),
customer(name), customer(name),
attachments(*) attachments(*)
''') ''')
@@ -278,7 +241,7 @@ class OperationsRepository {
} }
Future<void> copyFileToCustomer({ Future<void> copyFileToCustomer({
required OperationFileModel file, required AttachmentModel file,
required String customerId, required String customerId,
}) async { }) async {
await _supabase await _supabase
@@ -290,16 +253,28 @@ class OperationsRepository {
Future<void> deleteOperationFiles(List<AttachmentModel> files) async { Future<void> deleteOperationFiles(List<AttachmentModel> files) async {
if (files.isEmpty) return; if (files.isEmpty) return;
// 1. Prepariamo le liste di ID e di Percorsi // 1. Prepariamo le liste di ID e di Percorsi
final List<String> idsToDelete = files.map((f) => f.id!).toList(); final List<String> idsToDelete = [];
final List<String> storagePaths = files.map((f) => f.storagePath).toList(); 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 { try {
if (idsToDelete.isNotEmpty) {
await _supabase.from('attachment').delete().inFilter('id', idsToDelete);
await _supabase.storage.from('documents').remove(storagePathsToDelete);
}
if (idsToEdit.isNotEmpty) {
await _supabase await _supabase
.from('attachment') .from('attachment')
.update({'operation_id': null}) .update({'operation_id': null})
.inFilter('id', idsToDelete); .inFilter('id', idsToEdit);
}
await _supabase.storage.from('documents').remove(storagePaths);
} on PostgrestException catch (e) { } on PostgrestException catch (e) {
throw 'Errore database: ${e.message}'; throw 'Errore database: ${e.message}';
} catch (e) { } catch (e) {

View File

@@ -1,100 +0,0 @@
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
class OperationFileModel extends Equatable {
final String? id;
final DateTime? createdAt;
final String name;
final String extension;
final String storagePath;
final String operationId;
final int fileSize;
final Uint8List? localBytes;
const OperationFileModel({
this.id,
this.createdAt,
required this.name,
required this.extension,
required this.storagePath,
required this.operationId,
required this.fileSize,
this.localBytes,
});
bool get isLocal => localBytes != null;
// Trasforma i byte in qualcosa di leggibile (KB, MB, GB)
String get sizeFormatted {
if (fileSize <= 0) return "0 B";
const suffixes = ["B", "KB", "MB", "GB", "TB"];
var i = (fileSize.toString().length - 1) ~/ 3;
if (i >= suffixes.length) i = suffixes.length - 1;
double num = fileSize / (1 << (i * 10));
return "${num.toStringAsFixed(i == 0 ? 0 : 1)} ${suffixes[i]}";
}
bool get isPdf => extension.toLowerCase().replaceAll('.', '') == 'pdf';
OperationFileModel copyWith({
String? id,
DateTime? createdAt,
String? name,
String? extension,
String? storagePath,
String? operationId,
int? fileSize,
Uint8List? localBytes,
}) {
return OperationFileModel(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
name: name ?? this.name,
extension: extension ?? this.extension,
storagePath: storagePath ?? this.storagePath,
operationId: operationId ?? this.operationId,
fileSize: fileSize ?? this.fileSize,
localBytes: localBytes ?? this.localBytes,
);
}
factory OperationFileModel.fromMap(Map<String, dynamic> map) {
return OperationFileModel(
id: map['id'] as String,
createdAt: map['created_at'] != null
? DateTime.parse(map['created_at'])
: null,
name: map['name'] ?? '',
extension: map['extension'] ?? '',
storagePath: map['storage_path'] ?? '',
operationId: map['operation_id']?.toString() ?? '',
fileSize: map['file_size'] is int
? map['file_size']
: int.tryParse(map['file_size']?.toString() ?? '0') ?? 0,
);
}
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'name': name,
'extension': extension,
'storage_path': storagePath,
'operation_id': operationId,
'file_size': fileSize,
};
}
@override
List<Object?> get props => [
id,
createdAt,
name,
extension,
storagePath,
operationId,
fileSize,
localBytes,
];
}

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart';
enum OperationStatus { enum OperationStatus {
@@ -27,7 +28,9 @@ class OperationModel extends Equatable {
final DateTime? createdAt; final DateTime? createdAt;
final String type; final String type;
final String? providerId; final String? providerId;
final String? providerDisplayName;
final String? modelId; final String? modelId;
final String? modelDisplayName;
final String? description; final String? description;
final DateTime? expirationDate; final DateTime? expirationDate;
final String note; final String note;
@@ -35,13 +38,14 @@ class OperationModel extends Equatable {
final String batchUuid; final String batchUuid;
final String companyId; final String companyId;
final String storeId; final String storeId;
final String? storeDisplayName;
final int quantity; final int quantity;
final String? staffId; final String? staffId;
final String staffDisplayName; final String? staffDisplayName;
final String? lastCampaignId; final String? lastCampaignId;
final OperationStatus status; final OperationStatus status;
final String? customerId; final String? customerId;
final String customerDisplayName; final String? customerDisplayName;
final String reference; final String reference;
// ALLEGATI (Aggiunto) // ALLEGATI (Aggiunto)
@@ -52,7 +56,9 @@ class OperationModel extends Equatable {
this.createdAt, this.createdAt,
this.type = '', this.type = '',
this.providerId, this.providerId,
this.providerDisplayName,
this.modelId, this.modelId,
this.modelDisplayName,
this.description, this.description,
this.expirationDate, this.expirationDate,
this.note = '', this.note = '',
@@ -60,13 +66,14 @@ class OperationModel extends Equatable {
this.batchUuid = '', this.batchUuid = '',
required this.companyId, required this.companyId,
this.storeId = '', this.storeId = '',
this.storeDisplayName,
this.quantity = 1, this.quantity = 1,
this.staffId, this.staffId,
this.staffDisplayName = '', this.staffDisplayName,
this.lastCampaignId, this.lastCampaignId,
this.status = OperationStatus.draft, this.status = OperationStatus.draft,
this.customerId, this.customerId,
this.customerDisplayName = '', this.customerDisplayName,
this.reference = '', this.reference = '',
this.attachments = const [], this.attachments = const [],
}); });
@@ -76,7 +83,9 @@ class OperationModel extends Equatable {
DateTime? createdAt, DateTime? createdAt,
String? type, String? type,
String? providerId, String? providerId,
String? providerDisplayName,
String? modelId, String? modelId,
String? modelDisplayName,
String? description, String? description,
DateTime? expirationDate, DateTime? expirationDate,
String? note, String? note,
@@ -84,6 +93,7 @@ class OperationModel extends Equatable {
String? batchUuid, String? batchUuid,
String? companyId, String? companyId,
String? storeId, String? storeId,
String? storeDisplayName,
int? quantity, int? quantity,
String? staffId, String? staffId,
String? staffDisplayName, String? staffDisplayName,
@@ -98,7 +108,9 @@ class OperationModel extends Equatable {
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? this.createdAt,
type: type ?? this.type, type: type ?? this.type,
providerId: providerId ?? this.providerId, providerId: providerId ?? this.providerId,
providerDisplayName: providerDisplayName ?? this.providerDisplayName,
modelId: modelId ?? this.modelId, modelId: modelId ?? this.modelId,
modelDisplayName: modelDisplayName ?? this.modelDisplayName,
description: description ?? this.description, description: description ?? this.description,
expirationDate: expirationDate ?? this.expirationDate, expirationDate: expirationDate ?? this.expirationDate,
note: note ?? this.note, note: note ?? this.note,
@@ -106,6 +118,7 @@ class OperationModel extends Equatable {
batchUuid: batchUuid ?? this.batchUuid, batchUuid: batchUuid ?? this.batchUuid,
companyId: companyId ?? this.companyId, companyId: companyId ?? this.companyId,
storeId: storeId ?? this.storeId, storeId: storeId ?? this.storeId,
storeDisplayName: storeDisplayName ?? this.storeDisplayName,
quantity: quantity ?? this.quantity, quantity: quantity ?? this.quantity,
staffId: staffId ?? this.staffId, staffId: staffId ?? this.staffId,
staffDisplayName: staffDisplayName ?? this.staffDisplayName, staffDisplayName: staffDisplayName ?? this.staffDisplayName,
@@ -123,7 +136,9 @@ class OperationModel extends Equatable {
createdAt, createdAt,
type, type,
providerId, providerId,
providerDisplayName,
modelId, modelId,
modelDisplayName,
description, description,
expirationDate, expirationDate,
note, note,
@@ -131,6 +146,7 @@ class OperationModel extends Equatable {
batchUuid, batchUuid,
companyId, companyId,
storeId, storeId,
storeDisplayName,
quantity, quantity,
staffId, staffId,
staffDisplayName, staffDisplayName,
@@ -154,7 +170,9 @@ class OperationModel extends Equatable {
: null, : null,
type: map['type'] as String? ?? '', type: map['type'] as String? ?? '',
providerId: map['provider_id'] as String? ?? '', providerId: map['provider_id'] as String? ?? '',
providerDisplayName: "${map['provider']['name']}".myFormat(),
modelId: map['model_id'] as String? ?? '', modelId: map['model_id'] as String? ?? '',
modelDisplayName: "${map['model']['name_with_brand'] ?? ''}".myFormat(),
description: map['description'] as String? ?? '', description: map['description'] as String? ?? '',
expirationDate: map['expiration_date'] != null expirationDate: map['expiration_date'] != null
? DateTime.parse(map['expiration_date']) ? DateTime.parse(map['expiration_date'])
@@ -164,13 +182,22 @@ class OperationModel extends Equatable {
batchUuid: map['batch_uuid'] as String, batchUuid: map['batch_uuid'] as String,
companyId: map['company_id'] as String, companyId: map['company_id'] as String,
storeId: map['store_id'] as String? ?? '', storeId: map['store_id'] as String? ?? '',
storeDisplayName: "${map['store']['name']}".myFormat(),
quantity: map['quantity'] is int quantity: map['quantity'] is int
? map['quantity'] ? map['quantity']
: int.tryParse(map['quantity']?.toString() ?? '0') ?? 0, : int.tryParse(map['quantity']?.toString() ?? '0') ?? 0,
staffId: map['staff_id'] as String? ?? '', staffId: map['staff_id'] as String? ?? '',
staffDisplayName: "${map['staff_member']['name'] ?? ''}".myFormat(),
lastCampaignId: map['last_campaign_id'] as String? ?? '', lastCampaignId: map['last_campaign_id'] as String? ?? '',
status: OperationStatus.fromString(map['status']), status: OperationStatus.fromString(map['status']),
customerId: map['customer_id'] as String? ?? '', customerId: map['customer_id'] as String? ?? '',
customerDisplayName: "${map['customer']['name'] ?? ''}".myFormat(),
attachments:
(map['attachment'] as List?)
?.map((x) => AttachmentModel.fromMap(x))
.toList() ??
const [],
reference: map['reference'] as String? ?? '', reference: map['reference'] as String? ?? '',
); );
} }

View File

@@ -2,12 +2,14 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/core/utils/extensions.dart';
import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart';
import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart';
import 'package:flux/core/widgets/qr_upload_dialog.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart';
import 'package:flux/features/attachments/models/attachment_model.dart';
import 'package:flux/features/operations/blocs/operation_files_bloc.dart'; import 'package:flux/features/operations/blocs/operation_files_bloc.dart';
import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/operation_file_model.dart'; import 'package:flux/features/operations/models/operation_model.dart';
class AttachmentsSection extends StatelessWidget { class AttachmentsSection extends StatelessWidget {
const AttachmentsSection({super.key}); const AttachmentsSection({super.key});
@@ -227,11 +229,31 @@ class AttachmentsSection extends StatelessWidget {
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.copy), icon: const Icon(Icons.copy),
label: const Text("Copia in Cliente"), label: const Text("Copia in Cliente"),
onPressed: () => saveAndCopyFilesToCustomer( onPressed: () {
context, final cubit = context.read<OperationsCubit>();
state.selectedFiles, if (cubit.state.currentOperation?.customerId ==
null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context
.l10n
.operationFormAttachmentSectionNoCustomer,
), ),
), ),
);
} else {
context.read<OperationFilesBloc>().add(
LinkFilesToCustomerEvent(
customerId: cubit
.state
.currentOperation!
.customerId!,
),
);
}
},
),
], ],
), ),
), ),
@@ -278,9 +300,8 @@ class AttachmentsSection extends StatelessWidget {
// Salviamo forzatamente in bozza // Salviamo forzatamente in bozza
await cubit.saveCurrentOperation( await cubit.saveCurrentOperation(
isBozza: true, targetStatus: OperationStatus.draft,
shouldPop: false, shouldPop: false,
files: operationFilesBloc.state.localFiles,
); );
// Recuperiamo il servizio aggiornato con l'ID! // Recuperiamo il servizio aggiornato con l'ID!
@@ -321,39 +342,8 @@ class AttachmentsSection extends StatelessWidget {
} }
} }
// --- LOGICA DI COPIA AL CLIENTE ---
void saveAndCopyFilesToCustomer(
BuildContext context,
List<OperationFileModel> files,
) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Copia nei documenti Cliente"),
content: const Text(
"Vuoi copiare i file selezionati nell'anagrafica del cliente? \n\n"
"Attenzione: per procedere, la pratica attuale verrà prima salvata in stato BOZZA.",
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text("Annulla"),
),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
// 1. Diciamo al Cubit di salvare in Bozza e fare la copia
context.read<OperationsCubit>().saveAndCopyFileToCustomer(files);
},
child: const Text("Salva e Copia"),
),
],
),
);
}
// --- LOGICA DI VISUALIZZAZIONE OVERLAY --- // --- LOGICA DI VISUALIZZAZIONE OVERLAY ---
void _handleDoubleClick(BuildContext context, OperationFileModel file) { void _handleDoubleClick(BuildContext context, AttachmentModel file) {
showDialog( showDialog(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,

View File

@@ -1,6 +1,4 @@
import 'package:flutter/material.dart'; 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:flux/features/operations/models/operation_model.dart';
class GeneralInfoSection extends StatelessWidget { class GeneralInfoSection extends StatelessWidget {
@@ -34,7 +32,7 @@ class GeneralInfoSection extends StatelessWidget {
// Numero di Riferimento / Telefono // Numero di Riferimento / Telefono
TextFormField( TextFormField(
initialValue: operation.number, initialValue: operation.reference,
keyboardType: TextInputType keyboardType: TextInputType
.phone, // Fa aprire il tastierino numerico su mobile .phone, // Fa aprire il tastierino numerico su mobile
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -43,49 +41,6 @@ class GeneralInfoSection extends StatelessWidget {
border: OutlineInputBorder(), border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone), prefixIcon: Icon(Icons.phone),
), ),
onChanged: (val) {
context.read<OperationsCubit>().updateField(number: val);
},
),
const SizedBox(height: 16),
// I due Switch affiancati (Bozza e A buon fine)
Row(
children: [
Expanded(
child: SwitchListTile(
title: const Text("Bozza"),
subtitle: const Text(
"Pratica in lavorazione",
style: TextStyle(fontSize: 12),
),
value: operation.isBozza,
activeThumbColor: Colors.orange,
contentPadding: EdgeInsets.zero,
onChanged: (val) {
context.read<OperationsCubit>().updateField(isBozza: val);
},
),
),
const SizedBox(width: 16),
Expanded(
child: SwitchListTile(
title: const Text("A buon fine"),
subtitle: const Text(
"Esito positivo",
style: TextStyle(fontSize: 12),
),
value: operation.resultOk,
activeThumbColor: Colors.green,
contentPadding: EdgeInsets.zero,
onChanged: (val) {
context.read<OperationsCubit>().updateField(
resultOk: val,
);
},
),
),
],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -101,9 +56,6 @@ class GeneralInfoSection extends StatelessWidget {
border: OutlineInputBorder(), border: OutlineInputBorder(),
alignLabelWithHint: true, alignLabelWithHint: true,
), ),
onChanged: (val) {
context.read<OperationsCubit>().updateField(note: val);
},
), ),
], ],
), ),

View File

@@ -5,7 +5,6 @@ import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/operation_form_screen/attachment_section.dart'; import 'package:flux/features/operations/ui/operation_form_screen/attachment_section.dart';
import 'package:flux/features/operations/ui/operation_form_screen/customer_section.dart'; import 'package:flux/features/operations/ui/operation_form_screen/customer_section.dart';
import 'package:flux/features/operations/ui/operation_form_screen/general_info_section.dart'; import 'package:flux/features/operations/ui/operation_form_screen/general_info_section.dart';
import 'package:flux/features/operations/ui/operation_form_screen/operations_grid.dart';
class OperationFormScreen extends StatefulWidget { class OperationFormScreen extends StatefulWidget {
final String? operationId; final String? operationId;
@@ -34,9 +33,16 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
}); });
} }
void _performSave(BuildContext context, {required bool isBozza}) { void _performSave(
BuildContext context, {
required OperationStatus targetStatus,
required bool shouldPop,
}) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
context.read<OperationsCubit>().saveCurrentOperation(isBozza: isBozza); context.read<OperationsCubit>().saveCurrentOperation(
targetStatus: targetStatus,
shouldPop: shouldPop,
);
} }
@override @override
@@ -93,7 +99,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
IconButton( IconButton(
icon: const Icon(Icons.edit_note), icon: const Icon(Icons.edit_note),
tooltip: "Salva come Bozza", tooltip: "Salva come Bozza",
onPressed: () => _performSave(context, isBozza: true), onPressed: () => _performSave(
context,
targetStatus: OperationStatus.draft,
shouldPop: false,
),
), ),
IconButton( IconButton(
icon: const Icon( icon: const Icon(
@@ -101,7 +111,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
color: Colors.green, color: Colors.green,
), ),
tooltip: "Conferma Pratica", tooltip: "Conferma Pratica",
onPressed: () => _performSave(context, isBozza: false), onPressed: () => _performSave(
context,
targetStatus: OperationStatus.ok,
shouldPop: true,
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
], ],
@@ -120,9 +134,6 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
GeneralInfoSection(operation: operation), GeneralInfoSection(operation: operation),
const SizedBox(height: 24), const SizedBox(height: 24),
OperationsGrid(operation: operation),
const SizedBox(height: 32),
AttachmentsSection(), AttachmentsSection(),
const SizedBox(height: 32), const SizedBox(height: 32),
_buildBottomActionButtons(context, isSaving: isSaving), _buildBottomActionButtons(context, isSaving: isSaving),
@@ -152,7 +163,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
label: const Text("Salva in Bozza"), label: const Text("Salva in Bozza"),
onPressed: isSaving onPressed: isSaving
? null ? null
: () => _performSave(context, isBozza: true), : () => _performSave(
context,
targetStatus: OperationStatus.draft,
shouldPop: false,
),
), ),
), ),
@@ -173,7 +188,11 @@ class _OperationFormScreenState extends State<OperationFormScreen> {
), ),
onPressed: isSaving onPressed: isSaving
? null ? null
: () => _performSave(context, isBozza: false), : () => _performSave(
context,
targetStatus: OperationStatus.ok,
shouldPop: true,
),
), ),
), ),
], ],

View File

@@ -296,7 +296,7 @@ class _OperationMobileUploadScreenState
// Diciamo al BLoC di caricare tutti i file. // Diciamo al BLoC di caricare tutti i file.
// Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda) // Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda)
final bloc = context.read<OperationFilesBloc>(); final bloc = context.read<OperationFilesBloc>();
bloc.add(UploadMultipleOperationFilesEvent(_stagedFiles)); bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles));
// N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"!
} }

View File

@@ -1,201 +0,0 @@
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/operations/blocs/operations_cubit.dart';
import 'package:flux/features/operations/models/energy_operation_model.dart';
import 'package:flux/features/operations/models/entertainment_operation_model.dart';
import 'package:flux/features/operations/models/fin_operation_model.dart';
import 'package:flux/features/operations/models/operation_model.dart';
import 'package:flux/features/operations/ui/operation_form_screen/action_card.dart';
import 'package:flux/features/operations/ui/operation_form_screen/energy_operation_dialog.dart';
import 'package:flux/features/operations/ui/operation_form_screen/entertainment_operation_card.dart';
import 'package:flux/features/operations/ui/operation_form_screen/finance_operation_dialog.dart';
import 'package:flux/features/operations/ui/operation_form_screen/int_dialogs.dart'; // Assicurati di importare il modello
class OperationsGrid extends StatelessWidget {
final OperationModel operation;
const OperationsGrid({super.key, required this.operation});
@override
Widget build(BuildContext context) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.layers_outlined,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
"Servizi e Accessori",
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: Wrap(
spacing: 16,
runSpacing: 16,
alignment: WrapAlignment.center,
children: [
// --- CONTATORI SEMPLICI ---
ActionCard(
label: "AL",
count: operation.al,
icon: Icons.sim_card,
color: Colors.blue,
onTap: () => updateCountDialog(
context,
"AL",
operation.al,
(val) =>
context.read<OperationsCubit>().updateField(al: val),
),
),
ActionCard(
label: "MNP",
count: operation.mnp,
icon: Icons.phone_android,
color: Colors.indigo,
onTap: () => updateCountDialog(
context,
"MNP",
operation.mnp,
(val) =>
context.read<OperationsCubit>().updateField(mnp: val),
),
),
ActionCard(
label: "NIP",
count: operation.nip,
icon: Icons.compare_arrows,
color: Colors.cyan,
onTap: () => updateCountDialog(
context,
"NIP",
operation.nip,
(val) =>
context.read<OperationsCubit>().updateField(nip: val),
),
),
ActionCard(
label: "Unica",
count: operation.unica,
icon: Icons.all_inclusive,
color: Colors.purple,
onTap: () => updateCountDialog(
context,
"Unica",
operation.unica,
(val) => context.read<OperationsCubit>().updateField(
unica: val,
),
),
),
ActionCard(
label: "Telepass",
count: operation.telepass,
icon: Icons.directions_car,
color: Colors.amber.shade700,
onTap: () => updateCountDialog(
context,
"Telepass",
operation.telepass,
(val) => context.read<OperationsCubit>().updateField(
telepass: val,
),
),
),
// --- MODULI COMPLESSI (Le liste) ---
ActionCard(
label: "Energia",
count: operation.energyOperations.length,
icon: Icons.bolt,
color: Colors.green,
onTap: () async {
// Apriamo la modale e aspettiamo il risultato
final result =
await showDialog<List<EnergyOperationModel>>(
context: context,
builder: (context) => EnergyOperationDialog(
currentStoreId: operation.storeId,
initialOperations: operation
.energyOperations, // Passiamo la lista attuale
),
);
// Se l'utente ha premuto "Conferma" e non "Annulla" o tap fuori
if (result != null && context.mounted) {
context.read<OperationsCubit>().updateEnergyOperations(
result,
);
}
},
),
ActionCard(
label: "Finanziam.",
count: operation.finOperations.length,
icon: Icons.euro_symbol,
color: Colors.teal,
onTap: () async {
final result = await showDialog<List<FinOperationModel>>(
context: context,
builder: (context) => FinanceOperationDialog(
productCubit: context.read<ProductCubit>(),
currentStoreId: operation.storeId,
initialOperations: operation
.finOperations, // Passiamo la lista attuale
),
);
if (result != null && context.mounted) {
context.read<OperationsCubit>().updateFinOperations(
result,
);
}
},
),
ActionCard(
label: "Intratten.",
count: operation.entertainmentOperations.length,
icon: Icons.movie_filter_outlined,
color: Colors.purple,
onTap: () async {
final result =
await showDialog<List<EntertainmentOperationModel>>(
context: context,
builder: (context) => EntertainmentOperationDialog(
initialOperations:
operation.entertainmentOperations,
currentStoreId: operation.storeId,
),
);
if (result != null && context.mounted) {
context
.read<OperationsCubit>()
.updateEntertainmentOperations(result);
}
},
),
],
),
),
],
),
),
);
}
}

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/features/operations/blocs/operations_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/models/operation_model.dart';
import 'package:flux/features/operations/utils/operation_actions.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
// Importa i tuoi modelli e cubit // Importa i tuoi modelli e cubit
@@ -139,15 +138,6 @@ class _OperationsScreenState extends State<OperationsScreen> {
), ),
), ),
), ),
if (operation.isBozza)
const Chip(
label: Text(
"BOZZA",
style: TextStyle(fontSize: 10, color: Colors.white),
),
backgroundColor: Colors.orange,
visualDensity: VisualDensity.compact,
),
], ],
), ),
subtitle: Column( subtitle: Column(
@@ -155,21 +145,14 @@ class _OperationsScreenState extends State<OperationsScreen> {
children: [ children: [
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
"Pratica: ${operation.number}${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}", "Pratica: ${operation.reference}${operation.createdAt?.day}/${operation.createdAt?.month}/${operation.createdAt?.year}",
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
// I nostri mini-chip per i servizi attivati Row(
Wrap(
spacing: 6,
children: [ children: [
if (operation.al > 0 || operation.mnp > 0) Text(operation.type),
_miniBadge("📞 Tel", Colors.blue), const SizedBox(width: 8),
if (operation.energyOperations.isNotEmpty) _buildOperationStatus(operation.status),
_miniBadge("⚡ Energy", Colors.green),
if (operation.finOperations.isNotEmpty)
_miniBadge("💰 Fin", Colors.purple),
if (operation.entertainmentOperations.isNotEmpty)
_miniBadge("📺 Ent", Colors.red),
], ],
), ),
], ],
@@ -187,22 +170,31 @@ class _OperationsScreenState extends State<OperationsScreen> {
); );
} }
Widget _miniBadge(String text, Color color) { Widget _buildOperationStatus(OperationStatus status) {
return Container( Color color;
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), switch (status) {
decoration: BoxDecoration( case OperationStatus.canceled || OperationStatus.ko:
color: color.withValues(alpha: 0.1), color = Colors.grey.shade800;
borderRadius: BorderRadius.circular(4), break;
border: Border.all(color: color.withValues(alpha: 0.5)), case OperationStatus.waitingforaction || OperationStatus.draft:
), color = Colors.orange;
child: Text( break;
text, case OperationStatus.ok:
style: TextStyle( color = Colors.green;
color: color, break;
fontSize: 10, case OperationStatus.waitingfordeployment ||
fontWeight: FontWeight.bold, 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

@@ -1,82 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flux/core/blocs/session/session_cubit.dart';
import 'package:flux/features/master_data/store/bloc/store_cubit.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';
/// Avvia la creazione di un nuovo servizio partendo dalla selezione dell'operatore.
void startNewOperation(BuildContext context) {
final session = context.read<SessionCubit>().state;
final currentStoreId = session.currentStore?.id;
if (currentStoreId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("Seleziona uno store prima di iniziare")),
);
return;
}
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (modalContext) {
// Usiamo lo StoreCubit invece dello StaffCubit!
return BlocBuilder<StoreCubit, StoreState>(
builder: (context, storeState) {
// Recuperiamo lo staff assegnato a questo specifico store usando la mappa che avevi già creato
final storeStaff = storeState.staffByStore[currentStoreId] ?? [];
return Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
"Chi sta eseguendo l'operazione?",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 20),
if (storeStaff.isEmpty)
const Text(
"Nessun membro dello staff configurato per questo store.\nVai in Anagrafica > Negozi per assegnare il personale.",
textAlign: TextAlign.center,
),
...storeStaff.map(
(member) => ListTile(
leading: const CircleAvatar(child: Icon(Icons.person)),
title: Text(member.name),
onTap: () {
// 1. Inizializza il form nel Cubit
context.read<OperationsCubit>().initOperationForm(
existingOperation: OperationModel(
storeId: currentStoreId,
employeeId: member.id,
number: '',
createdAt: DateTime.now(),
companyId: session.company!.id!,
),
);
// 2. Chiudi la modal
Navigator.pop(modalContext);
// 3. Naviga verso il form
context.pushNamed('operation-form');
},
),
),
const SizedBox(height: 16),
],
),
);
},
);
},
);
}

View File

@@ -1,13 +0,0 @@
{
"@@locale": "en",
"welcomeBack": "Welcome back, {name}! 👋",
"latestOperations": "Latest Operations",
"masterData": "Master Data",
"settings": "Settings",
"newOperation": "Operation",
"expiring_contracts": "Expiring Contracts",
"sticky_notes": "Sticky Notes",
"my_tasks": "My Tasks",
"latest_operation_tickets": "Latest operation tickets"
}

View File

@@ -86,5 +86,6 @@
"createCompanyScreenWillBeUsedForReceipts": "Verrà utilizzato per le tue stampe e ricevute", "createCompanyScreenWillBeUsedForReceipts": "Verrà utilizzato per le tue stampe e ricevute",
"createCompanyScreenSaveCompany": "SALVA AZIENDA", "createCompanyScreenSaveCompany": "SALVA AZIENDA",
"createCompanyScreenSetupYourCompany": "Configura la tua Azienda", "createCompanyScreenSetupYourCompany": "Configura la tua Azienda",
"createCompanyScreenFluxNeedsYourFiscalData": "FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi." "createCompanyScreenFluxNeedsYourFiscalData": "FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.",
"operationFormAttachmentSectionNoCustomer": "Devi prima selezionare un cliente"
} }

View File

@@ -441,6 +441,12 @@ abstract class AppLocalizations {
/// In it, this message translates to: /// In it, this message translates to:
/// **'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.'** /// **'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.'**
String get createCompanyScreenFluxNeedsYourFiscalData; String get createCompanyScreenFluxNeedsYourFiscalData;
/// No description provided for @operationFormAttachmentSectionNoCustomer.
///
/// In it, this message translates to:
/// **'Devi prima selezionare un cliente'**
String get operationFormAttachmentSectionNoCustomer;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View File

@@ -199,4 +199,8 @@ class AppLocalizationsIt extends AppLocalizations {
@override @override
String get createCompanyScreenFluxNeedsYourFiscalData => String get createCompanyScreenFluxNeedsYourFiscalData =>
'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.'; 'FLUX ha bisogno dei tuoi dati fiscali per gestire correttamente le fatturazioni e le attivazioni dei tuoi negozi.';
@override
String get operationFormAttachmentSectionNoCustomer =>
'Devi prima selezionare un cliente';
} }

View File

@@ -1 +0,0 @@
{}

View File

@@ -1035,7 +1035,7 @@ packages:
source: hosted source: hosted
version: "3.1.5" version: "3.1.5"
uuid: uuid:
dependency: transitive dependency: "direct main"
description: description:
name: uuid name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"

View File

@@ -28,6 +28,7 @@ dependencies:
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
shared_preferences: ^2.5.5 shared_preferences: ^2.5.5
supabase_flutter: ^2.12.2 supabase_flutter: ^2.12.2
uuid: ^4.5.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: