From 9cc5dd6a4f707f8c37b8021ed5c3d5fb4b4a2c30 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Tue, 5 May 2026 12:11:38 +0200 Subject: [PATCH 01/13] 1 Co-authored-by: Copilot --- .../ui/widgets/operation_files_section.dart | 3 +- .../tickets/blocs/ticket_list_cubit.dart | 73 ++++ .../tickets/blocs/ticket_list_state.dart | 59 +++ .../tickets/data/ticket_repository.dart | 235 ++++++++++++ lib/features/tickets/models/ticket_model.dart | 345 ++++++++++++++++++ .../models/ticket_status_extension.dart | 43 +++ 6 files changed, 756 insertions(+), 2 deletions(-) create mode 100644 lib/features/tickets/blocs/ticket_list_cubit.dart create mode 100644 lib/features/tickets/blocs/ticket_list_state.dart create mode 100644 lib/features/tickets/data/ticket_repository.dart create mode 100644 lib/features/tickets/models/ticket_model.dart create mode 100644 lib/features/tickets/models/ticket_status_extension.dart diff --git a/lib/features/operations/ui/widgets/operation_files_section.dart b/lib/features/operations/ui/widgets/operation_files_section.dart index 42b1a6c..e7ee5d5 100644 --- a/lib/features/operations/ui/widgets/operation_files_section.dart +++ b/lib/features/operations/ui/widgets/operation_files_section.dart @@ -13,7 +13,6 @@ 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 { @@ -281,7 +280,7 @@ class _OperationFilesSectionState extends State { if (fileBytes == null) continue; // Recuperiamo il nome che l'utente ha (magari) già impostato - final baseName = file.name ?? 'Documento'; + final baseName = file.name; if (file.extension == 'pdf') { final document = await px.PdfDocument.openData(fileBytes); diff --git a/lib/features/tickets/blocs/ticket_list_cubit.dart b/lib/features/tickets/blocs/ticket_list_cubit.dart new file mode 100644 index 0000000..5ad1616 --- /dev/null +++ b/lib/features/tickets/blocs/ticket_list_cubit.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +import 'package:flux/features/tickets/data/ticket_repository.dart'; +import 'package:get_it/get_it.dart'; +import 'ticket_list_state.dart'; + +class TicketListCubit extends Cubit { + final TicketRepository _repository = GetIt.I.get(); + static const int _limit = 20; // Paginazione a blocchi di 20 + + TicketListCubit() : super(const TicketListState()) { + fetchTickets(reset: true); + } + + /// Recupera i ticket. Se reset = true, svuota la lista e riparte da offset 0. + Future fetchTickets({bool reset = false}) async { + if (state.isLoading) return; + if (!reset && state.hasReachedMax) return; + + emit( + state.copyWith( + isLoading: true, + errorMessage: '', + tickets: reset ? [] : state.tickets, + ), + ); + + try { + final currentOffset = reset ? 0 : state.tickets.length; + + final newTickets = await _repository.fetchStoreTickets( + offset: currentOffset, + limit: _limit, + searchTerm: state.searchTerm, + dateRange: state.dateRange, + statusFilter: state.statusFilter, + ); + + emit( + state.copyWith( + tickets: reset ? newTickets : [...state.tickets, ...newTickets], + isLoading: false, + hasReachedMax: newTickets.length < _limit, + ), + ); + } catch (e) { + emit(state.copyWith(isLoading: false, errorMessage: e.toString())); + } + } + + /// Aggiorna i filtri e ricarica tutto da zero + void updateFilters({ + String? searchTerm, + DateTimeRange? dateRange, + TicketStatus? statusFilter, + bool clearSearch = false, + bool clearDate = false, + bool clearStatus = false, + }) { + emit( + state.copyWith( + searchTerm: searchTerm, + dateRange: dateRange, + statusFilter: statusFilter, + clearSearch: clearSearch, + clearDate: clearDate, + clearStatus: clearStatus, + ), + ); + fetchTickets(reset: true); // Applica i filtri e ricarica + } +} diff --git a/lib/features/tickets/blocs/ticket_list_state.dart b/lib/features/tickets/blocs/ticket_list_state.dart new file mode 100644 index 0000000..d36caa5 --- /dev/null +++ b/lib/features/tickets/blocs/ticket_list_state.dart @@ -0,0 +1,59 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; + +class TicketListState extends Equatable { + final List tickets; + final bool isLoading; + final bool hasReachedMax; + final String errorMessage; + + // Filtri attivi + final String? searchTerm; + final DateTimeRange? dateRange; + final TicketStatus? statusFilter; + + const TicketListState({ + this.tickets = const [], + this.isLoading = false, + this.hasReachedMax = false, + this.errorMessage = '', + this.searchTerm, + this.dateRange, + this.statusFilter, + }); + + TicketListState copyWith({ + List? tickets, + bool? isLoading, + bool? hasReachedMax, + String? errorMessage, + String? searchTerm, + DateTimeRange? dateRange, + TicketStatus? statusFilter, + bool clearSearch = false, + bool clearDate = false, + bool clearStatus = false, + }) { + return TicketListState( + tickets: tickets ?? this.tickets, + isLoading: isLoading ?? this.isLoading, + hasReachedMax: hasReachedMax ?? this.hasReachedMax, + errorMessage: errorMessage ?? this.errorMessage, + searchTerm: clearSearch ? null : (searchTerm ?? this.searchTerm), + dateRange: clearDate ? null : (dateRange ?? this.dateRange), + statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter), + ); + } + + @override + List get props => [ + tickets, + isLoading, + hasReachedMax, + errorMessage, + searchTerm, + dateRange, + statusFilter, + ]; +} diff --git a/lib/features/tickets/data/ticket_repository.dart b/lib/features/tickets/data/ticket_repository.dart new file mode 100644 index 0000000..4adc9a3 --- /dev/null +++ b/lib/features/tickets/data/ticket_repository.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class TicketRepository { + final SupabaseClient _supabase = GetIt.I.get(); + + TicketRepository(); + + static const String _tableName = 'ticket'; + + // --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI UNO STORE --- + Future> fetchStoreTickets({ + required int offset, + int limit = 50, + String? searchTerm, + DateTimeRange? dateRange, + TicketStatus? statusFilter, + TicketType? ticketTypeFilter, + String? staffIdFilter, + }) async { + try { + var query = _supabase + .from(_tableName) + .select(''' + *, + customer (*), + staff (*), + target_model:model!ticket_model_id_1_fkey (*), + source_model:model!ticket_model_id_2_fkey (*) + ''') + .eq('store_id', GetIt.I.get().state.currentStore!.id!); + + // Filtro Range Date + if (dateRange != null) { + query = query + .gte('created_at', dateRange.start.toIso8601String()) + .lte('created_at', dateRange.end.toIso8601String()); + } + + if (statusFilter != null) { + query = query.eq('status', statusFilter.value); + } + + if (ticketTypeFilter != null) { + query = query.eq('ticket_type', ticketTypeFilter.value); + } + + if (staffIdFilter != null) { + query = query.eq('staff_id', staffIdFilter); + } + + if (searchTerm != null && searchTerm.isNotEmpty) { + // Filtra sui campi della tabella principale O su quelli della tabella joinata + query = query.or('customer.name.ilike.%$searchTerm%'); + } + + final response = await query + .order('created_at', ascending: false) + .range(offset, offset + limit - 1); + + return (response as List).map((map) => TicketModel.fromMap(map)).toList(); + } catch (e) { + throw Exception('$e'); + } + } + + // --- RECUPERO PAGINATO CON FILTRI E JOIN DEI TICKET DI TUTTA L'AZIENDA --- + Future> fetchCompanyTickets({ + required int offset, + int limit = 50, + String? searchTerm, + DateTimeRange? dateRange, + TicketStatus? ticketStatusFilter, + TicketType? ticketTypeFilter, + String? staffIdFilter, + }) async { + try { + var query = _supabase + .from(_tableName) + .select(''' + *, + customer (*), + staff (*), + target_model:model!ticket_model_id_1_fkey (*), + source_model:model!ticket_model_id_2_fkey (*) + ''') + .eq('company_id', GetIt.I.get().state.company!.id!); + + // Filtro Range Date + if (dateRange != null) { + query = query + .gte('created_at', dateRange.start.toIso8601String()) + .lte('created_at', dateRange.end.toIso8601String()); + } + + if (ticketStatusFilter != null) { + query = query.eq('status', ticketStatusFilter.value); + } + + if (ticketTypeFilter != null) { + query = query.eq('ticket_type', ticketTypeFilter.value); + } + + if (staffIdFilter != null) { + query = query.eq('staff_id', staffIdFilter); + } + + if (searchTerm != null && searchTerm.isNotEmpty) { + // Filtra sui campi della tabella principale O su quelli della tabella joinata + query = query.or('customer.name.ilike.%$searchTerm%'); + } + + final response = await query + .order('created_at', ascending: false) + .range(offset, offset + limit - 1); + + return (response as List).map((map) => TicketModel.fromMap(map)).toList(); + } catch (e) { + throw Exception('$e'); + } + } + + /// Stream dei ticket che necessitano attenzione (es. in scadenza oggi o in ritardo) + Stream> getAttentionNeededTicketsStream() { + return _supabase + .from(_tableName) + .stream(primaryKey: ['id']) + .eq('store_id', GetIt.I.get().state.currentStore!.id!) + // Purtroppo lo stream accetta solo filtri base, quindi ci facciamo + // mandare i dati e li filtriamo con la potenza di Dart! + .limit(300) + .map((listOfMaps) { + final now = DateTime.now(); + final endOfToday = DateTime(now.year, now.month, now.day, 23, 59, 59); + + // 1. Mappiamo tutto in TicketModel + final allStoreTickets = listOfMaps + .map((map) => TicketModel.fromMap(map)) + .toList(); + + // 2. Filtriamo in memoria! + final urgentTickets = allStoreTickets.where((ticket) { + // Escludiamo quelli già chiusi o consegnati + if (ticket.status == TicketStatus.closed || + ticket.status == TicketStatus.ready) { + return false; + } + + // Se c'è una data di consegna stimata ed è <= a stasera, è urgente! + if (ticket.estimatedDeliveryAt != null) { + return ticket.estimatedDeliveryAt!.isBefore(endOfToday); + } + + return false; + }).toList(); + + // 3. Li ordiniamo mettendo i più vecchi/urgenti in cima + urgentTickets.sort( + (a, b) => a.estimatedDeliveryAt!.compareTo(b.estimatedDeliveryAt!), + ); + + return urgentTickets; + }); + } + + /// Recupera un ticket specifico CON TUTTE LE RELAZIONI espanse (Cliente e Modelli) + /// Questa è la vera magia di Supabase! + Future getTicketWithDetails(String ticketId) async { + try { + // Usiamo i nomi esatti delle Foreign Key che hai definito nell'SQL! + final response = await _supabase + .from(_tableName) + .select(''' + *, + customer (*), + target_model:model!ticket_model_id_1_fkey (*), + source_model:model!ticket_model_id_2_fkey (*), + staff:staff_member!ticket_staff_id_fkey (*) + ''') + .eq('id', ticketId) + .single(); + + return TicketModel.fromMap(response); + } catch (e) { + throw Exception('Errore nel recupero del dettaglio ticket: $e'); + } + } + + /// Salva il ticket con upsert + Future saveTicket(TicketModel ticket) async { + try { + final response = await _supabase + .from(_tableName) + .upsert(ticket.toMap()) + .select() + .single(); + + return TicketModel.fromMap(response); + } catch (e) { + throw Exception('Errore nella creazione del ticket: $e'); + } + } + + /// Aggiorna un ticket esistente + Future updateTicket(TicketModel ticket) async { + if (ticket.id == null) { + throw Exception('Impossibile aggiornare un ticket senza ID'); + } + + try { + final response = await _supabase + .from(_tableName) + .update(ticket.toMap()) + .eq('id', ticket.id!) + .select() + .single(); + + return TicketModel.fromMap(response); + } catch (e) { + throw Exception('Errore nell\'aggiornamento del ticket: $e'); + } + } + + /// Elimina (o annulla) un ticket + Future deleteTicket(String ticketId) async { + try { + await _supabase.from(_tableName).delete().eq('id', ticketId); + } catch (e) { + throw Exception('Errore nell\'eliminazione del ticket: $e'); + } + } +} diff --git a/lib/features/tickets/models/ticket_model.dart b/lib/features/tickets/models/ticket_model.dart new file mode 100644 index 0000000..70c06d2 --- /dev/null +++ b/lib/features/tickets/models/ticket_model.dart @@ -0,0 +1,345 @@ +import 'package:equatable/equatable.dart'; +import 'package:flux/core/utils/extensions.dart'; + +/// Enum per il tipo di ticket +enum TicketType { + repair('repair', 'Riparazione'), + softwareSetup('software_setup', 'Setup software'), + dataTransfer('data_transfer', 'Trasferimento dati'), + operationTicket('operation_ticket', 'Ticket di operazione'), + other('other', 'Altro'); + + final String value; + final String displayValue; + const TicketType(this.value, this.displayValue); + + static TicketType fromString(String val) { + return TicketType.values.firstWhere( + (e) => e.value == val, + orElse: () => TicketType.other, + ); + } +} + +/// Enum per lo stato del ticket +enum TicketStatus { + open('open', 'Aperto'), + inProgress('in_progress', 'In corso'), + waitingForParts('waiting_for_parts', 'In attesa di ricambi'), + ready('ready', 'Pronto'), + closed('closed', 'Chiuso'), + waitingForShipping('waiting_for_shipping', 'In attesa di spedire'), + waitingForReturn('waiting_for_return', 'In attesa di ritorno'); + + final String value; + final String displayValue; + const TicketStatus(this.value, this.displayValue); + + static TicketStatus? fromString(String? val) { + if (val == null) return null; + return TicketStatus.values.firstWhere( + (e) => e.value == val, + orElse: () => TicketStatus.open, + ); + } +} + +/// Enum per il risultato del ticket (OK / KO) +enum TicketResult { + success('success', 'Risolto (OK)'), + failure('failure', 'Non Risolto (KO)'); + + final String value; + final String displayValue; + const TicketResult(this.value, this.displayValue); + + static TicketResult? fromString(String? val) { + if (val == null) return null; + return TicketResult.values.firstWhere( + (e) => e.value == val, + orElse: () => TicketResult.success, + ); + } +} + +/// Enum per il tipo di garanzia +enum WarrantyType { + manufacturerWarranty('manufacturer_warranty', 'Garanzia produttore'), + providerWarranty('provider_warranty', 'Garanzia gestore'), + internalWarranty('internal_warranty', 'Garanzia interna'), + noWarranty('no_warranty', 'Fuori garanzia'); + + final String value; + final String displayValue; + const WarrantyType(this.value, this.displayValue); + + static WarrantyType? fromString(String? val) { + return WarrantyType.values.firstWhere( + (e) => e.value == val, + orElse: () => WarrantyType.noWarranty, + ); + } +} + +class TicketModel extends Equatable { + final String? id; // Null se non ancora salvato + final DateTime? createdAt; + final String companyId; + final String? storeId; + final String? customerId; + final String? targetModelId; + final String? targetSn; + final String? sourceModelId; + final String? sourceSn; + final double customerPrice; + final double internalCost; + final DateTime? closedAt; + final DateTime? returnedAt; + final String request; + final String? staffId; + final WarrantyType? warrantyType; + final String? publicNotes; + final String? internalNotes; + final int? referenceNumber; + final String? alternativePhoneNumber; + final bool hasCourtesyDevice; + final TicketType ticketType; + final TicketStatus? status; + final DateTime? estimatedDeliveryAt; + final TicketResult? result; + final String? resolutionNotes; + final String? legacyId; + final String? customerName; + final String? targetModelName; + final String? sourceModelName; + final String? staffName; + + const TicketModel({ + this.id, + this.createdAt, + required this.companyId, + this.storeId, + this.customerId, + this.targetModelId, + this.targetSn, + this.sourceModelId, + this.sourceSn, + this.customerPrice = 0.0, + this.internalCost = 0.0, + this.closedAt, + this.returnedAt, + this.request = '', + this.staffId, + this.warrantyType, + this.publicNotes, + this.internalNotes, + this.referenceNumber, + this.alternativePhoneNumber, + this.hasCourtesyDevice = false, + required this.ticketType, + this.status, + this.estimatedDeliveryAt, + this.result, + this.resolutionNotes, + this.legacyId, + this.customerName, + this.targetModelName, + this.sourceModelName, + this.staffName, + }); + + /// Factory per creare un ticket vuoto (utile per i form di creazione) + factory TicketModel.empty({required String companyId, String? storeId}) { + return TicketModel( + companyId: companyId, + storeId: storeId, + ticketType: TicketType.repair, // Valore di default + status: TicketStatus.open, + customerPrice: 0.0, + internalCost: 0.0, + hasCourtesyDevice: false, + request: '', + ); + } + + TicketModel copyWith({ + String? id, + DateTime? createdAt, + String? companyId, + String? storeId, + String? customerId, + String? targetModelId, + String? targetSn, + String? sourceModelId, + String? sourceSn, + double? customerPrice, + double? internalCost, + DateTime? closedAt, + DateTime? returnedAt, + String? request, + String? staffId, + WarrantyType? warrantyType, + String? publicNotes, + String? internalNotes, + int? referenceNumber, + String? alternativePhoneNumber, + bool? hasCourtesyDevice, + TicketType? ticketType, + TicketStatus? status, + DateTime? estimatedDeliveryAt, + TicketResult? result, + String? resolutionNotes, + String? legacyId, + String? customerName, + String? targetModelName, + String? sourceModelName, + String? staffName, + }) { + return TicketModel( + id: id ?? this.id, + createdAt: createdAt ?? this.createdAt, + companyId: companyId ?? this.companyId, + storeId: storeId ?? this.storeId, + customerId: customerId ?? this.customerId, + targetModelId: targetModelId ?? this.targetModelId, + targetSn: targetSn ?? this.targetSn, + sourceModelId: sourceModelId ?? this.sourceModelId, + sourceSn: sourceSn ?? this.sourceSn, + customerPrice: customerPrice ?? this.customerPrice, + internalCost: internalCost ?? this.internalCost, + closedAt: closedAt ?? this.closedAt, + returnedAt: returnedAt ?? this.returnedAt, + request: request ?? this.request, + staffId: staffId ?? this.staffId, + warrantyType: warrantyType ?? this.warrantyType, + publicNotes: publicNotes ?? this.publicNotes, + internalNotes: internalNotes ?? this.internalNotes, + referenceNumber: referenceNumber ?? this.referenceNumber, + alternativePhoneNumber: + alternativePhoneNumber ?? this.alternativePhoneNumber, + hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice, + ticketType: ticketType ?? this.ticketType, + status: status ?? this.status, + estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt, + result: result ?? this.result, + resolutionNotes: resolutionNotes ?? this.resolutionNotes, + legacyId: legacyId ?? this.legacyId, + customerName: customerName ?? this.customerName, + targetModelName: targetModelName ?? this.targetModelName, + sourceModelName: sourceModelName ?? this.sourceModelName, + staffName: staffName ?? this.staffName, + ); + } + + /// Deserializzazione da Supabase + factory TicketModel.fromMap(Map map) { + return TicketModel( + id: map['id'] as String, + createdAt: map['created_at'] != null + ? DateTime.parse(map['created_at']).toLocal() + : null, + companyId: map['company_id'] as String, + storeId: map['store_id'] as String?, + customerId: map['customer_id'] as String?, + targetModelId: map['target_model_id'] as String?, + targetSn: map['target_sn'] as String?, + sourceModelId: map['source_model_id'] as String?, + sourceSn: map['source_sn'] as String?, + // Fix per i field numerici di Postgres che potrebbero arrivare come int o double + customerPrice: (map['customer_price'] as num?)?.toDouble() ?? 0.0, + internalCost: (map['internal_cost'] as num?)?.toDouble() ?? 0.0, + closedAt: map['closed_at'] != null + ? DateTime.parse(map['closed_at']).toLocal() + : null, + returnedAt: map['returned_at'] != null + ? DateTime.parse(map['returned_at']).toLocal() + : null, + request: map['request'] as String? ?? '', + staffId: map['staff_id'] as String?, + warrantyType: WarrantyType.fromString(map['warranty_type'] as String?), + publicNotes: map['public_notes'] as String?, + internalNotes: map['internal_notes'] as String?, + referenceNumber: map['reference_number'] as int?, + alternativePhoneNumber: map['alternative_phone_number'] as String?, + hasCourtesyDevice: map['has_courtesy_device'] as bool? ?? false, + ticketType: TicketType.fromString(map['ticket_type'] as String), + status: TicketStatus.fromString(map['status'] as String?), + estimatedDeliveryAt: map['estimated_delivery_at'] != null + ? DateTime.parse(map['estimated_delivery_at']).toLocal() + : null, + result: TicketResult.fromString(map['result'] as String?), + resolutionNotes: map['resolution_notes'] as String?, + legacyId: map['legacy_id'] as String?, + customerName: (map['customer']?['name'] as String?).myFormat(), + targetModelName: (map['target_model']?['name_with_brand'] as String?) + ?.myFormat(), + sourceModelName: (map['source_model']?['name_with_brand'] as String?) + ?.myFormat(), + staffName: (map['staff']?['name'] as String?).myFormat(), + ); + } + + /// Serializzazione per Supabase + Map toMap() { + return { + if (id != null) 'id': id, + 'company_id': companyId, + 'store_id': storeId, + 'customer_id': customerId, + 'target_model_id': targetModelId, + 'target_sn': targetSn, + 'source_model_id': sourceModelId, + 'source_sn': sourceSn, + 'customer_price': customerPrice, + 'internal_cost': internalCost, + if (closedAt != null) 'closed_at': closedAt!.toUtc().toIso8601String(), + if (returnedAt != null) + 'returned_at': returnedAt!.toUtc().toIso8601String(), + 'request': request, + 'staff_id': staffId, + 'warranty_type': warrantyType, + 'public_notes': publicNotes, + 'internal_notes': internalNotes, + 'alternative_phone_number': alternativePhoneNumber, + 'has_courtesy_device': hasCourtesyDevice, + 'ticket_type': ticketType.value, + if (status != null) 'status': status!.value, + if (estimatedDeliveryAt != null) + 'estimated_delivery_at': estimatedDeliveryAt!.toUtc().toIso8601String(), + if (result != null) 'result': result!.value, + 'resolution_notes': resolutionNotes, + 'legacy_id': legacyId, + }; + } + + @override + List get props => [ + id, + createdAt, + companyId, + storeId, + customerId, + targetModelId, + targetSn, + sourceModelId, + sourceSn, + customerPrice, + internalCost, + closedAt, + returnedAt, + request, + staffId, + warrantyType, + publicNotes, + internalNotes, + referenceNumber, + alternativePhoneNumber, + hasCourtesyDevice, + ticketType, + status, + estimatedDeliveryAt, + result, + resolutionNotes, + legacyId, + ]; +} diff --git a/lib/features/tickets/models/ticket_status_extension.dart b/lib/features/tickets/models/ticket_status_extension.dart new file mode 100644 index 0000000..00660fe --- /dev/null +++ b/lib/features/tickets/models/ticket_status_extension.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; + +extension TicketStatusVisuals on TicketStatus { + Color get color { + switch (this) { + case TicketStatus.open: + return Colors.blueGrey; + case TicketStatus.waitingForParts: + return Colors.amber.shade700; + case TicketStatus.inProgress: + return Colors.blue; + case TicketStatus.waitingForShipping: + // Il tuo rosa storico! + return Colors.pinkAccent; + case TicketStatus.waitingForReturn: + return Colors.purpleAccent; + case TicketStatus.ready: + return Colors.green; + case TicketStatus.closed: + return Colors.grey.shade400; + } + } + + IconData get icon { + switch (this) { + case TicketStatus.open: + return Icons.inbox; + case TicketStatus.waitingForParts: + return Icons.hourglass_empty; + case TicketStatus.inProgress: + return Icons.build; + case TicketStatus.waitingForShipping: + return Icons.local_shipping_outlined; + case TicketStatus.waitingForReturn: + return Icons.undo; + case TicketStatus.ready: + return Icons.check_circle_outline; + case TicketStatus.closed: + return Icons.lock_outline; + } + } +} -- 2.43.0 From 0c8b9ae3ec1ffebe4b83d6ca8cee82e6d96d3d5a Mon Sep 17 00:00:00 2001 From: mark-cachy Date: Tue, 5 May 2026 19:29:20 +0200 Subject: [PATCH 02/13] 2 --- .../tickets/blocs/ticket_list_cubit.dart | 6 + .../tickets/blocs/ticket_list_state.dart | 10 + .../tickets/ui/ticket_list_screen.dart | 291 ++++++++++++++++++ lib/main.dart | 2 + 4 files changed, 309 insertions(+) create mode 100644 lib/features/tickets/ui/ticket_list_screen.dart diff --git a/lib/features/tickets/blocs/ticket_list_cubit.dart b/lib/features/tickets/blocs/ticket_list_cubit.dart index 5ad1616..20aa312 100644 --- a/lib/features/tickets/blocs/ticket_list_cubit.dart +++ b/lib/features/tickets/blocs/ticket_list_cubit.dart @@ -35,6 +35,8 @@ class TicketListCubit extends Cubit { searchTerm: state.searchTerm, dateRange: state.dateRange, statusFilter: state.statusFilter, + ticketTypeFilter: state.ticketTypeFilter, + staffIdFilter: state.staffIdFilter, ); emit( @@ -54,6 +56,8 @@ class TicketListCubit extends Cubit { String? searchTerm, DateTimeRange? dateRange, TicketStatus? statusFilter, + TicketType? ticketTypeFilter, + String? staffIdFilter, bool clearSearch = false, bool clearDate = false, bool clearStatus = false, @@ -63,6 +67,8 @@ class TicketListCubit extends Cubit { searchTerm: searchTerm, dateRange: dateRange, statusFilter: statusFilter, + ticketTypeFilter: ticketTypeFilter, + staffIdFilter: staffIdFilter, clearSearch: clearSearch, clearDate: clearDate, clearStatus: clearStatus, diff --git a/lib/features/tickets/blocs/ticket_list_state.dart b/lib/features/tickets/blocs/ticket_list_state.dart index d36caa5..a2b9636 100644 --- a/lib/features/tickets/blocs/ticket_list_state.dart +++ b/lib/features/tickets/blocs/ticket_list_state.dart @@ -12,6 +12,8 @@ class TicketListState extends Equatable { final String? searchTerm; final DateTimeRange? dateRange; final TicketStatus? statusFilter; + final TicketType? ticketTypeFilter; + final String? staffIdFilter; const TicketListState({ this.tickets = const [], @@ -21,6 +23,8 @@ class TicketListState extends Equatable { this.searchTerm, this.dateRange, this.statusFilter, + this.ticketTypeFilter, + this.staffIdFilter, }); TicketListState copyWith({ @@ -31,6 +35,8 @@ class TicketListState extends Equatable { String? searchTerm, DateTimeRange? dateRange, TicketStatus? statusFilter, + TicketType? ticketTypeFilter, + String? staffIdFilter, bool clearSearch = false, bool clearDate = false, bool clearStatus = false, @@ -43,6 +49,8 @@ class TicketListState extends Equatable { searchTerm: clearSearch ? null : (searchTerm ?? this.searchTerm), dateRange: clearDate ? null : (dateRange ?? this.dateRange), statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter), + ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter, + staffIdFilter: staffIdFilter ?? this.staffIdFilter, ); } @@ -55,5 +63,7 @@ class TicketListState extends Equatable { searchTerm, dateRange, statusFilter, + ticketTypeFilter, + staffIdFilter, ]; } diff --git a/lib/features/tickets/ui/ticket_list_screen.dart b/lib/features/tickets/ui/ticket_list_screen.dart new file mode 100644 index 0000000..43a0503 --- /dev/null +++ b/lib/features/tickets/ui/ticket_list_screen.dart @@ -0,0 +1,291 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart'; +import 'package:flux/features/tickets/blocs/ticket_list_state.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +import 'package:flux/features/tickets/models/ticket_status_extension.dart'; + +class TicketListScreen extends StatefulWidget { + const TicketListScreen({super.key}); + + @override + State createState() => _TicketListScreenState(); +} + +class _TicketListScreenState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + // INFINITY SCROLL: Quando arriviamo quasi in fondo, chiediamo altri ticket + _scrollController.addListener(() { + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + context.read().fetchTickets(); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Assistenza & Riparazioni'), + actions: [ + // Tasto per filtri avanzati (Data, Staff, Tipo) -> Da fare in un BottomSheet! + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: () { + // TODO: Aprire BottomSheet filtri avanzati + }, + ), + ], + ), + body: Column( + children: [ + // 1. BARRA DI RICERCA + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Cerca per nome cliente...', + prefixIcon: const Icon(Icons.search), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + context.read().updateFilters( + clearSearch: true, + ); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onSubmitted: (value) { + context.read().updateFilters( + searchTerm: value, + ); + }, + ), + ), + + // 2. FILTRI RAPIDI PER STATO (CHIPS) + BlocBuilder( + buildWhen: (previous, current) => + previous.statusFilter != current.statusFilter, + builder: (context, state) { + return SizedBox( + height: 50, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + children: [ + _buildStatusChip(context, state, null, 'Tutti'), + ...TicketStatus.values.map( + (status) => _buildStatusChip( + context, + state, + status, + status.displayValue, + ), + ), + ], + ), + ); + }, + ), + const Divider(), + + // 3. LA LISTA DEI TICKET + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.isLoading && state.tickets.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.tickets.isEmpty) { + return const Center(child: Text('Nessun ticket trovato.')); + } + + return ListView.builder( + controller: _scrollController, + itemCount: state.hasReachedMax + ? state.tickets.length + : state.tickets.length + 1, + itemBuilder: (context, index) { + // Se siamo all'ultimo elemento e non abbiamo raggiunto il max, mostriamo il loader + if (index >= state.tickets.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ); + } + + final ticket = state.tickets[index]; + return _TicketCard(ticket: ticket); + }, + ); + }, + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + // TODO: Navigare alla creazione di un nuovo ticket + }, + icon: const Icon(Icons.add), + label: const Text('Nuovo Ticket'), + ), + ); + } + + // Widget di supporto per creare le Chip di filtro + Widget _buildStatusChip( + BuildContext context, + TicketListState state, + TicketStatus? status, + String label, + ) { + final isSelected = state.statusFilter == status; + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ChoiceChip( + label: Text(label), + selected: isSelected, + selectedColor: + status?.color.withValues(alpha: 0.2) ?? + Colors.blue.withValues(alpha: 0.2), + onSelected: (selected) { + context.read().updateFilters( + statusFilter: selected ? status : null, + clearStatus: !selected && status != null, + ); + }, + ), + ); + } +} + +// --------------------------------------------------------- +// LA CARD DEL TICKET (Il "Colpo d'Occhio") +// --------------------------------------------------------- +class _TicketCard extends StatelessWidget { + final TicketModel ticket; + + const _TicketCard({required this.ticket}); + + @override + Widget build(BuildContext context) { + final statusColor = ticket.status?.color ?? Colors.grey; + final statusIcon = ticket.status?.icon ?? Icons.help_outline; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + clipBehavior: Clip + .antiAlias, // Serve per tagliare il container laterale con gli angoli della card + child: IntrinsicHeight( + // Serve per far sì che il container laterale prenda tutta l'altezza + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // LA STRISCIA COLORATA LATERALE + Container(width: 6, color: statusColor), + Expanded( + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + ticket.customerName ?? 'Cliente Sconosciuto', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // IL BADGE DELLO STATO + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: statusColor.withValues(alpha: 0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 14, color: statusColor), + const SizedBox(width: 4), + Text( + ticket.status?.displayValue ?? 'N/D', + style: TextStyle( + fontSize: 12, + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + // MODELLO O TIPO DI INTERVENTO + Text( + ticket.targetModelName ?? ticket.ticketType.displayValue, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + // DATA CREAZIONE (Es: 04/05/2026) + Text( + ticket.createdAt != null + ? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}' + : '', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ], + ), + onTap: () { + // TODO: Aprire il dettaglio del ticket! + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 8f62822..4d3873b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/auth/bloc/auth_cubit.dart'; import 'package:flux/features/operations/data/operations_repository.dart'; +import 'package:flux/features/tickets/data/ticket_repository.dart'; import 'package:flux/l10n/app_localizations.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -92,6 +93,7 @@ Future setupLocator() async { getIt.registerLazySingleton( () => AttachmentsRepository(), ); + getIt.registerLazySingleton(() => TicketRepository()); // NOTA: CompanyRepository l'ho tolto perché la logica della Company // ora è gestita dal CoreRepository durante l'Onboarding. -- 2.43.0 From 1d45912fc78e7f2e0bb654c593dfac50ef968120 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 00:08:25 +0200 Subject: [PATCH 03/13] feat: setup completo architettura ticket, UI e cubit --- lib/core/routes/app_router.dart | 9 + .../ui/latest_store_operations_card.dart | 237 +++++++++--------- lib/features/home/ui/home_screen.dart | 108 ++++---- .../tickets/data/ticket_repository.dart | 4 +- 4 files changed, 189 insertions(+), 169 deletions(-) diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 1ac0980..6f9ccac 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -29,6 +29,8 @@ import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart'; import 'package:flux/features/operations/ui/operations_screen.dart'; +import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart'; +import 'package:flux/features/tickets/ui/ticket_list_screen.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -146,6 +148,13 @@ class AppRouter { builder: (context, state) => const CustomersContent(), // O come si chiama il tuo widget della lista! ), + GoRoute( + path: '/tickets', + builder: (context, state) => BlocProvider( + create: (context) => TicketListCubit(), + child: const TicketListScreen(), + ), + ), ], ), diff --git a/lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart b/lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart index 1380b0a..2832680 100644 --- a/lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart +++ b/lib/features/home/latest_store_operations/ui/latest_store_operations_card.dart @@ -47,30 +47,30 @@ class _LatestOperationsCardContent extends StatelessWidget { borderRadius: BorderRadius.circular(16), side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)), ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // --- HEADER DELLA CARD --- - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), + child: InkWell( + onTap: () => context.push('/operations'), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- HEADER DELLA CARD --- + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.design_services_outlined, + color: color, + size: 20, + ), ), - child: const Icon( - Icons.design_services_outlined, - color: color, - size: 20, - ), - ), - const SizedBox(width: 12), - Expanded( - child: TextButton( - onPressed: () => context.push('/operations'), + const SizedBox(width: 12), + Expanded( child: Text( context.l10n.homeLatestOperations, style: TextStyle( @@ -82,106 +82,111 @@ class _LatestOperationsCardContent extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - ), - ], - ), - const SizedBox(height: 12), + ], + ), + const SizedBox(height: 12), - // --- CORPO DELLA CARD (LA LISTA REAL-TIME) --- - Expanded( - child: - BlocBuilder< - LatestStoreOperationsBloc, - LatestStoreOperationsState - >( - builder: (context, state) { - if (state.status == LatestStoreOperationsStatus.loading || - state.status == LatestStoreOperationsStatus.initial) { - return const Center(child: CircularProgressIndicator()); - } + // --- CORPO DELLA CARD (LA LISTA REAL-TIME) --- + Expanded( + child: + BlocBuilder< + LatestStoreOperationsBloc, + LatestStoreOperationsState + >( + builder: (context, state) { + if (state.status == + LatestStoreOperationsStatus.loading || + state.status == + LatestStoreOperationsStatus.initial) { + return const Center( + child: CircularProgressIndicator(), + ); + } - if (state.status == LatestStoreOperationsStatus.failure) { - return Center( - child: Text( - "Errore di caricamento", - style: TextStyle(color: theme.colorScheme.error), - ), - ); - } - - if (state.operations.isEmpty) { - return Center( - child: Text( - "Nessun servizio recente.", - style: TextStyle( - color: context.secondaryText, - fontStyle: FontStyle.italic, + if (state.status == + LatestStoreOperationsStatus.failure) { + return Center( + child: Text( + "Errore di caricamento", + style: TextStyle(color: theme.colorScheme.error), ), - ), - ); - } + ); + } - return ListView.separated( - itemCount: state.operations.length, - separatorBuilder: (context, index) => Divider( - height: 1, - color: theme.dividerColor.withValues(alpha: 0.3), - ), - itemBuilder: (context, index) { - final operation = state.operations[index]; - return InkWell( - onTap: () => context.push( - '/operation-form', - extra: operation, - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0, - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Expanded( - flex: 5, - child: Text( - operation.customerDisplayName ?? - 'Cliente sconosciuto', - style: TextStyle( - fontWeight: FontWeight.w700, - color: context.primaryText, - ), - ), - ), - Expanded( - flex: 5, - child: Text( - operation.reference, - style: TextStyle( - fontWeight: FontWeight.w600, - color: context.primaryText, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Text( - "${operation.createdAt?.day}/${operation.createdAt?.month}", - style: TextStyle( - color: context.secondaryText, - fontSize: 12, - ), - ), - ], + if (state.operations.isEmpty) { + return Center( + child: Text( + "Nessun servizio recente.", + style: TextStyle( + color: context.secondaryText, + fontStyle: FontStyle.italic, ), ), ); - }, - ); - }, - ), - ), - ], + } + + return ListView.separated( + itemCount: state.operations.length, + separatorBuilder: (context, index) => Divider( + height: 1, + color: theme.dividerColor.withValues(alpha: 0.3), + ), + itemBuilder: (context, index) { + final operation = state.operations[index]; + return InkWell( + onTap: () => context.push( + '/operation-form', + extra: operation, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 5, + child: Text( + operation.customerDisplayName ?? + 'Cliente sconosciuto', + style: TextStyle( + fontWeight: FontWeight.w700, + color: context.primaryText, + ), + ), + ), + Expanded( + flex: 5, + child: Text( + operation.reference, + style: TextStyle( + fontWeight: FontWeight.w600, + color: context.primaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + "${operation.createdAt?.day}/${operation.createdAt?.month}", + style: TextStyle( + color: context.secondaryText, + fontSize: 12, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), ), ), ); diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 40bef0a..9029872 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -77,12 +77,13 @@ class HomeScreen extends StatelessWidget { context: context, ), LatestStoreOperationsCard(), - _buildDashboardWidget( title: context.l10n.homeLatestOperationTickets, icon: Icons.support_agent_outlined, color: Colors.purple, context: context, + onTap: () => + context.push('/tickets'), // <-- Aggiunto! ), ]), ), @@ -194,8 +195,8 @@ class HomeScreen extends StatelessWidget { label: context.l10n.homeNewOperationTicket, color: Colors.redAccent, onTap: () { - // TODO: Quando avrai la rotta per la nuova assistenza - // context.push('/assistance-form'); + // Andiamo alla lista! (Da lì poi aggiungeremo il tasto "+" per il form) + context.push('/tickets'); }, ), const SizedBox(width: 12), @@ -226,68 +227,73 @@ class HomeScreen extends StatelessWidget { required String title, required IconData icon, required Color color, + VoidCallback? onTap, }) { final theme = Theme.of(context); return Card( elevation: 0, + clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide(color: theme.dividerColor.withValues(alpha: 0.5)), ), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: color, size: 20), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - title, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: context.primaryText, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Icon(icon, color: color, size: 20), ), - ), - IconButton( - icon: Icon( - Icons.more_vert, - size: 20, - color: context.secondaryText, + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: context.primaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: Icon( + Icons.more_vert, + size: 20, + color: context.secondaryText, + ), + onPressed: () {}, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + const Spacer(), + Center( + child: Text( + context.l10n.commonComingSoon, + style: TextStyle( + color: context.secondaryText.withValues(alpha: 0.7), + fontStyle: FontStyle.italic, + fontSize: 13, ), - onPressed: () {}, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - ), - ], - ), - const Spacer(), - Center( - child: Text( - context.l10n.commonComingSoon, - style: TextStyle( - color: context.secondaryText.withValues(alpha: 0.7), - fontStyle: FontStyle.italic, - fontSize: 13, ), ), - ), - const Spacer(), - ], + const Spacer(), + ], + ), ), ), ); diff --git a/lib/features/tickets/data/ticket_repository.dart b/lib/features/tickets/data/ticket_repository.dart index 4adc9a3..df30905 100644 --- a/lib/features/tickets/data/ticket_repository.dart +++ b/lib/features/tickets/data/ticket_repository.dart @@ -27,7 +27,7 @@ class TicketRepository { .select(''' *, customer (*), - staff (*), + staff_member (*), target_model:model!ticket_model_id_1_fkey (*), source_model:model!ticket_model_id_2_fkey (*) ''') @@ -83,7 +83,7 @@ class TicketRepository { .select(''' *, customer (*), - staff (*), + staff_member (*), target_model:model!ticket_model_id_1_fkey (*), source_model:model!ticket_model_id_2_fkey (*) ''') -- 2.43.0 From 5207a82706d154fd1e4745d9418090b0d9d9ba0f Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 08:58:23 +0200 Subject: [PATCH 04/13] rese agnostiche sezioni customer e staff per le form. Inizio di lavoro per rendere agnostico il bloc degli allegati --- lib/core/routes/app_router.dart | 2 +- .../shared_forms}/customer_section.dart | 27 +++++++---------- .../operation_files_section.dart | 2 +- .../widgets/shared_forms}/staff_section.dart | 18 +++++++----- .../blocs/operation_files_bloc.dart | 0 .../blocs/operation_files_events.dart | 0 .../blocs/operation_files_state.dart | 0 .../operations/ui/operation_form_screen.dart | 29 ++++++++++++++----- .../ui/operation_mobile_upload_screen.dart | 2 +- lib/features/tickets/models/ticket_model.dart | 11 +++++++ 10 files changed, 57 insertions(+), 34 deletions(-) rename lib/{features/operations/ui/widgets => core/widgets/shared_forms}/customer_section.dart (89%) rename lib/{features/operations/ui/widgets => core/widgets/shared_forms}/operation_files_section.dart (99%) rename lib/{features/operations/ui/widgets => core/widgets/shared_forms}/staff_section.dart (92%) rename lib/features/{operations => attachments}/blocs/operation_files_bloc.dart (100%) rename lib/features/{operations => attachments}/blocs/operation_files_events.dart (100%) rename lib/features/{operations => attachments}/blocs/operation_files_state.dart (100%) diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 6f9ccac..4ad187b 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -24,7 +24,7 @@ import 'package:flux/features/master_data/staff/ui/staff_screen.dart'; import 'package:flux/features/master_data/store/ui/stores_screen.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; -import 'package:flux/features/operations/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/blocs/operation_files_bloc.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart'; diff --git a/lib/features/operations/ui/widgets/customer_section.dart b/lib/core/widgets/shared_forms/customer_section.dart similarity index 89% rename from lib/features/operations/ui/widgets/customer_section.dart rename to lib/core/widgets/shared_forms/customer_section.dart index 88ec939..4c1c9ce 100644 --- a/lib/features/operations/ui/widgets/customer_section.dart +++ b/lib/core/widgets/shared_forms/customer_section.dart @@ -1,13 +1,19 @@ 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/models/customer_model.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}); + final ValueChanged onCustomerSelected; + + const CustomerSection({ + super.key, + required this.currentOp, + required this.onCustomerSelected, + }); @override Widget build(BuildContext context) { @@ -125,9 +131,6 @@ class CustomerSection extends StatelessWidget { icon: const Icon(Icons.person_add), label: const Text('Crea Nuovo Cliente'), onPressed: () async { - final OperationsCubit operationsCubit = context - .read(); - // APRIAMO LA DIALOG RAPIDA CON LA MAGIA DEL BLOC PROVIDER final newCustomer = await showDialog( context: context, @@ -145,10 +148,7 @@ class CustomerSection extends StatelessWidget { // 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, - ); + onCustomerSelected(newCustomer); // 2. Chiudiamo la BottomSheet dei clienti per tornare alla form! if (context.mounted) { @@ -196,14 +196,7 @@ class CustomerSection extends StatelessWidget { '${customer.phoneNumber} • ${customer.email}', ), onTap: () { - // Aggiorniamo il form tramite il Cubit delle operazioni - context - .read() - .updateOperationFields( - customerId: customer.id, // customer.id - customerDisplayName: - customer.name, // customer.name - ); + onCustomerSelected(customer); Navigator.pop(modalContext); }, ); diff --git a/lib/features/operations/ui/widgets/operation_files_section.dart b/lib/core/widgets/shared_forms/operation_files_section.dart similarity index 99% rename from lib/features/operations/ui/widgets/operation_files_section.dart rename to lib/core/widgets/shared_forms/operation_files_section.dart index e7ee5d5..5a0dc36 100644 --- a/lib/features/operations/ui/widgets/operation_files_section.dart +++ b/lib/core/widgets/shared_forms/operation_files_section.dart @@ -7,7 +7,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart'; import 'package:flux/features/attachments/ui/quick_rename_dialog.dart'; -import 'package:flux/features/operations/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/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'; diff --git a/lib/features/operations/ui/widgets/staff_section.dart b/lib/core/widgets/shared_forms/staff_section.dart similarity index 92% rename from lib/features/operations/ui/widgets/staff_section.dart rename to lib/core/widgets/shared_forms/staff_section.dart index 00967b1..fe705b4 100644 --- a/lib/features/operations/ui/widgets/staff_section.dart +++ b/lib/core/widgets/shared_forms/staff_section.dart @@ -2,16 +2,19 @@ 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/master_data/staff/models/staff_member_model.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; + final ValueChanged onStaffSelected; - const StaffSection({super.key, required this.currentOp}); + const StaffSection({ + super.key, + required this.currentOp, + required this.onStaffSelected, + }); @override Widget build(BuildContext context) { @@ -49,11 +52,12 @@ class StaffSection extends StatelessWidget { return GestureDetector( onTap: () { - // Aggiorniamo la form con un solo tap! - context.read().updateOperationFields( + onStaffSelected(staff); + + /* context.read().updateOperationFields( staffId: staff.id, staffDisplayName: staff.name, - ); + ); */ }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), diff --git a/lib/features/operations/blocs/operation_files_bloc.dart b/lib/features/attachments/blocs/operation_files_bloc.dart similarity index 100% rename from lib/features/operations/blocs/operation_files_bloc.dart rename to lib/features/attachments/blocs/operation_files_bloc.dart diff --git a/lib/features/operations/blocs/operation_files_events.dart b/lib/features/attachments/blocs/operation_files_events.dart similarity index 100% rename from lib/features/operations/blocs/operation_files_events.dart rename to lib/features/attachments/blocs/operation_files_events.dart diff --git a/lib/features/operations/blocs/operation_files_state.dart b/lib/features/attachments/blocs/operation_files_state.dart similarity index 100% rename from lib/features/operations/blocs/operation_files_state.dart rename to lib/features/attachments/blocs/operation_files_state.dart diff --git a/lib/features/operations/ui/operation_form_screen.dart b/lib/features/operations/ui/operation_form_screen.dart index 40f7193..28e7b35 100644 --- a/lib/features/operations/ui/operation_form_screen.dart +++ b/lib/features/operations/ui/operation_form_screen.dart @@ -3,12 +3,11 @@ 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/core/widgets/shared_forms/customer_section.dart'; import 'package:flux/features/operations/ui/widgets/details_section.dart'; -import 'package:flux/features/operations/ui/widgets/operation_files_section.dart'; -import 'package:flux/features/operations/ui/widgets/staff_section.dart'; -import 'package:get_it/get_it.dart'; // ASSICURATI DEL PATH -// import 'package:flux/features/attachments/ui/operation_files_section.dart'; +import 'package:flux/core/widgets/shared_forms/operation_files_section.dart'; +import 'package:flux/core/widgets/shared_forms/staff_section.dart'; +import 'package:get_it/get_it.dart'; class OperationFormScreen extends StatefulWidget { final String? operationId; @@ -317,10 +316,26 @@ class _OperationFormScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - StaffSection(currentOp: currentOp), + StaffSection( + currentOp: currentOp, + onStaffSelected: (staff) => { + context.read().updateOperationFields( + staffId: staff.id, + staffDisplayName: staff.name, + ), + }, + ), const Divider(height: 50), _buildSectionTitle('Cliente & Riferimento'), - CustomerSection(currentOp: currentOp), + CustomerSection( + currentOp: currentOp, + onCustomerSelected: (customer) { + context.read().updateOperationFields( + customerId: customer.id, + customerDisplayName: customer.name, + ); + }, + ), const SizedBox(height: 16), TextFormField( controller: _referenceController, diff --git a/lib/features/operations/ui/operation_mobile_upload_screen.dart b/lib/features/operations/ui/operation_mobile_upload_screen.dart index 730efd0..ad916c7 100644 --- a/lib/features/operations/ui/operation_mobile_upload_screen.dart +++ b/lib/features/operations/ui/operation_mobile_upload_screen.dart @@ -1,7 +1,7 @@ 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:flux/features/attachments/blocs/operation_files_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; diff --git a/lib/features/tickets/models/ticket_model.dart b/lib/features/tickets/models/ticket_model.dart index 70c06d2..74a7b2b 100644 --- a/lib/features/tickets/models/ticket_model.dart +++ b/lib/features/tickets/models/ticket_model.dart @@ -113,6 +113,7 @@ class TicketModel extends Equatable { final String? targetModelName; final String? sourceModelName; final String? staffName; + final String? includedAccessories; const TicketModel({ this.id, @@ -146,6 +147,7 @@ class TicketModel extends Equatable { this.targetModelName, this.sourceModelName, this.staffName, + this.includedAccessories, }); /// Factory per creare un ticket vuoto (utile per i form di creazione) @@ -194,6 +196,7 @@ class TicketModel extends Equatable { String? targetModelName, String? sourceModelName, String? staffName, + String? includedAccessories, }) { return TicketModel( id: id ?? this.id, @@ -228,6 +231,7 @@ class TicketModel extends Equatable { targetModelName: targetModelName ?? this.targetModelName, sourceModelName: sourceModelName ?? this.sourceModelName, staffName: staffName ?? this.staffName, + includedAccessories: includedAccessories ?? this.includedAccessories, ); } @@ -276,6 +280,7 @@ class TicketModel extends Equatable { sourceModelName: (map['source_model']?['name_with_brand'] as String?) ?.myFormat(), staffName: (map['staff']?['name'] as String?).myFormat(), + includedAccessories: map['included_accessories'] as String?, ); } @@ -309,6 +314,7 @@ class TicketModel extends Equatable { if (result != null) 'result': result!.value, 'resolution_notes': resolutionNotes, 'legacy_id': legacyId, + 'included_accessories': includedAccessories, }; } @@ -341,5 +347,10 @@ class TicketModel extends Equatable { result, resolutionNotes, legacyId, + includedAccessories, + customerName, + targetModelName, + sourceModelName, + staffName, ]; } -- 2.43.0 From ec06155f2b2f3f2d5eb26912d7c87870f05cd8aa Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 10:17:48 +0200 Subject: [PATCH 05/13] fantascenza attachment bloc agnostico, ora continuo refactor rimuovendo customer file bloc ecc. --- lib/core/routes/app_router.dart | 12 +- .../shared_forms/operation_files_section.dart | 45 +- .../attachments/blocs/attachments_bloc.dart | 391 ++++++++++++++++++ .../attachments/blocs/attachments_events.dart | 68 +++ ...iles_state.dart => attachments_state.dart} | 51 ++- .../blocs/operation_files_bloc.dart | 389 ----------------- .../blocs/operation_files_events.dart | 81 ---- .../data/attachments_repository.dart | 187 ++++++++- .../attachments/models/attachment_model.dart | 7 + .../ui/operation_mobile_upload_screen.dart | 12 +- 10 files changed, 715 insertions(+), 528 deletions(-) create mode 100644 lib/features/attachments/blocs/attachments_bloc.dart create mode 100644 lib/features/attachments/blocs/attachments_events.dart rename lib/features/attachments/blocs/{operation_files_state.dart => attachments_state.dart} (53%) delete mode 100644 lib/features/attachments/blocs/operation_files_bloc.dart delete mode 100644 lib/features/attachments/blocs/operation_files_events.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 4ad187b..0160e15 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -24,7 +24,7 @@ import 'package:flux/features/master_data/staff/ui/staff_screen.dart'; import 'package:flux/features/master_data/store/ui/stores_screen.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; -import 'package:flux/features/attachments/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart'; @@ -203,8 +203,9 @@ class AppRouter { context.read().loadStaffForStore(currentStoreId); return BlocProvider( - create: (context) => OperationFilesBloc( - operationId: operationId ?? existingOperation?.id, + create: (context) => AttachmentsBloc( + parentId: operationId ?? existingOperation?.id, + parentType: AttachmentParentType.operation, ), child: OperationFormScreen( operationId: operationId ?? existingOperation?.id, @@ -232,7 +233,10 @@ class AppRouter { context.read().loadBrands(); context.read().loadStaffForStore(currentStoreId); return BlocProvider( - create: (context) => OperationFilesBloc(operationId: operationId), + create: (context) => AttachmentsBloc( + parentId: operationId, + parentType: AttachmentParentType.operation, + ), child: OperationMobileUploadScreen( operationId: operationId, operationName: operationName, diff --git a/lib/core/widgets/shared_forms/operation_files_section.dart b/lib/core/widgets/shared_forms/operation_files_section.dart index 5a0dc36..56f5620 100644 --- a/lib/core/widgets/shared_forms/operation_files_section.dart +++ b/lib/core/widgets/shared_forms/operation_files_section.dart @@ -7,7 +7,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flux/features/attachments/data/attachments_repository.dart'; import 'package:flux/features/attachments/ui/attachment_viewer_screen.dart'; import 'package:flux/features/attachments/ui/quick_rename_dialog.dart'; -import 'package:flux/features/attachments/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:flux/features/operations/models/operation_model.dart'; @@ -88,16 +88,14 @@ class _OperationFilesSectionState extends State { if (result != null && mounted) { // MAGIA: Passiamo direttamente la lista di PlatformFile al tuo BLoC! - context.read().add( - AddOperationFilesEvent(result.files), - ); + context.read().add(AddAttachmentsEvent(result.files)); } } // --- APERTURA VIEWER --- void _openFile(AttachmentModel file) { // 1. Catturiamo il BLoC dalla pagina corrente prima di navigare - final operationFilesBloc = context.read(); + final operationFilesBloc = context.read(); Navigator.push( context, MaterialPageRoute( @@ -107,10 +105,10 @@ class _OperationFilesSectionState extends State { attachment: file, onRename: (newName) { // Spara l'evento al BLoC e lui farà il resto! - operationFilesBloc.add(RenameOperationFileEvent(file, newName)); + operationFilesBloc.add(RenameAttachmentEvent(file, newName)); }, onDelete: () { - operationFilesBloc.add(DeleteSpecificOperationFileEvent(file)); + operationFilesBloc.add(DeleteSpecificAttachmentEvent(file)); }, ), ), @@ -392,7 +390,7 @@ class _OperationFilesSectionState extends State { final theme = Theme.of(context); // USIAMO IL TUO BLOC! - return BlocBuilder( + return BlocBuilder( builder: (context, state) { final allFiles = state.allFiles; final selectedFiles = state.selectedFiles; @@ -442,7 +440,7 @@ class _OperationFilesSectionState extends State { ElevatedButton.icon( icon: const Icon(Icons.add_photo_alternate), label: const Text('Aggiungi File'), - onPressed: state.status == OperationFilesStatus.uploading + onPressed: state.status == AttachmentsStatus.uploading ? null : _pickFiles, ), @@ -463,12 +461,12 @@ class _OperationFilesSectionState extends State { ), onPressed: () { if (selectedFiles.length == allFiles.length) { - context.read().add( - ClearOperationFileSelectionEvent(), + context.read().add( + ClearAttachmentSelectionEvent(), ); } else { - context.read().add( - SelectAllOperationFilesEvent(), + context.read().add( + SelectAllAttachmentsEvent(), ); } }, @@ -477,7 +475,7 @@ class _OperationFilesSectionState extends State { const SizedBox(width: 12), // Loader di upload - if (state.status == OperationFilesStatus.uploading) + if (state.status == AttachmentsStatus.uploading) const SizedBox( width: 24, height: 24, @@ -493,8 +491,8 @@ class _OperationFilesSectionState extends State { icon: const Icon(Icons.delete, color: Colors.red), tooltip: 'Elimina selezionati', onPressed: () { - context.read().add( - DeleteOperationFilesEvent(), + context.read().add( + DeleteAttachmentsEvent(), ); }, ), @@ -505,9 +503,10 @@ class _OperationFilesSectionState extends State { icon: const Icon(Icons.person_add, color: Colors.blue), tooltip: 'Copia nei documenti del Cliente', onPressed: () { - context.read().add( - LinkFilesToCustomerEvent( - customerId: widget.currentOp.customerId!, + context.read().add( + LinkAttachmentsToEntityEvent( + targetId: widget.currentOp.customerId!, + targetType: AttachmentParentType.customer, ), ); ScaffoldMessenger.of(context).showSnackBar( @@ -621,8 +620,8 @@ class _OperationFilesSectionState extends State { onTap: () => _openFile(file), onLongPress: () { // Selezione rapida con long press! - context.read().add( - ToggleOperationFileSelectionEvent(file), + context.read().add( + ToggleAttachmentSelectionEvent(file), ); }, borderRadius: BorderRadius.circular(8), @@ -696,8 +695,8 @@ class _OperationFilesSectionState extends State { right: 4, child: InkWell( onTap: () { - context.read().add( - ToggleOperationFileSelectionEvent(file), + context.read().add( + ToggleAttachmentSelectionEvent(file), ); }, child: Container( diff --git a/lib/features/attachments/blocs/attachments_bloc.dart b/lib/features/attachments/blocs/attachments_bloc.dart new file mode 100644 index 0000000..3c2b665 --- /dev/null +++ b/lib/features/attachments/blocs/attachments_bloc.dart @@ -0,0 +1,391 @@ +import 'dart:async'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/utils/extensions.dart'; +import 'package:flux/features/attachments/data/attachments_repository.dart'; +import 'package:flux/features/attachments/models/attachment_model.dart'; +import 'package:get_it/get_it.dart'; +import 'package:image_picker/image_picker.dart'; + +part 'attachments_events.dart'; +part 'attachments_state.dart'; + +class AttachmentsBloc extends Bloc { + final _repository = GetIt.I.get(); + + AttachmentsBloc({String? parentId, required AttachmentParentType parentType}) + : super( + AttachmentsState( + status: AttachmentsStatus.initial, + parentId: parentId, + parentType: parentType, + ), + ) { + on(_onParentEntitySaved); + on(_onLoadAttachments); + on(_onAddAttachments); + on(_onUploadAttachments); + on(_onDeleteAttachments); + on(_onToggleAttachmentSelection); + on(_onLinkAttachmentsToEntity); + on(_onRenameAttachment); + on(_onDeleteSpecificAttachment); + on(_onSelectAllAttachments); + on(_onClearAttachmentSelection); + + // Se il BLoC nasce già con un ID, carichiamo i file + if (parentId != null) { + add(LoadAttachmentsEvent(parentId: parentId)); + } + } + FutureOr _onParentEntitySaved( + ParentEntitySavedEvent event, + Emitter emit, + ) async { + emit( + state.copyWith( + parentId: event.newParentId, + status: AttachmentsStatus.uploading, + ), + ); + + if (state.localFiles.isNotEmpty) { + try { + final List> uploadTasks = state.localFiles.map((file) { + final fakePlatformFile = PlatformFile( + name: '${file.name}.${file.extension}', + size: file.fileSize, + bytes: file.localBytes, + ); + + // Chiamiamo il metodo generico passando il parentId e il TYPE + return _repository.uploadAndRegisterFile( + parentId: event.newParentId, + parentType: state.parentType, + pickedFile: fakePlatformFile, + ); + }).toList(); + + await Future.wait(uploadTasks); + } catch (e) { + emit( + state.copyWith( + status: AttachmentsStatus.failure, + error: "Errore upload post-salvataggio: $e", + ), + ); + return; + } + } + + emit(state.copyWith(localFiles: [], status: AttachmentsStatus.success)); + add(LoadAttachmentsEvent(parentId: event.newParentId)); + } + + FutureOr _onLoadAttachments( + LoadAttachmentsEvent event, + Emitter emit, + ) async { + final currentId = event.parentId ?? state.parentId; + + if (currentId != null) { + emit(state.copyWith(status: AttachmentsStatus.loading)); + + await emit.forEach( + _repository.getFilesStream( + currentId, + state.parentType, + ), // Passiamo il tipo! + onData: (List data) => state.copyWith( + status: AttachmentsStatus.success, + remoteFiles: data, + ), + onError: (error, stackTrace) => state.copyWith( + status: AttachmentsStatus.failure, + error: error.toString(), + ), + ); + } + } + + void _onAddAttachments( + AddAttachmentsEvent event, + Emitter emit, + ) async { + final currentId = state.parentId; + + // BIVIO 1: PRATICA NUOVA (Salvataggio locale) + if (currentId == null) { + final companyId = GetIt.I.get().state.company!.id!; + final newLocalFiles = event.files.map((file) { + // Assegniamo i campi dinamicamente in base al parentType! + return AttachmentModel( + id: null, + companyId: companyId, + operationId: state.parentType == AttachmentParentType.operation + ? '' + : null, + ticketId: state.parentType == AttachmentParentType.ticket ? '' : null, + customerId: state.parentType == AttachmentParentType.customer + ? '' + : null, + name: file.name.fileNameWithoutExtension(), + extension: file.name.fileExtension(), + storagePath: '', + fileSize: file.size, + localBytes: file.bytes, + ); + }).toList(); + + emit( + state.copyWith( + localFiles: [...state.localFiles, ...newLocalFiles], + status: AttachmentsStatus.success, + ), + ); + return; + } + + // BIVIO 2: PRATICA ESISTENTE (Upload immediato) + emit(state.copyWith(status: AttachmentsStatus.uploading)); + try { + final List> uploadTasks = event.files.map((file) { + return _repository.uploadAndRegisterFile( + parentId: currentId, + parentType: state.parentType, + pickedFile: file, + ); + }).toList(); + + await Future.wait(uploadTasks); + emit(state.copyWith(status: AttachmentsStatus.success)); + } catch (e) { + emit( + state.copyWith(status: AttachmentsStatus.failure, error: e.toString()), + ); + } + } + + FutureOr _onUploadAttachments( + UploadAttachmentsEvent event, + Emitter emit, + ) async { + if ((event.pickedFiles == null || event.pickedFiles!.isEmpty) && + (event.photos == null || event.photos!.isEmpty)) { + return; + } + + if (state.parentId == null) return; + + emit(state.copyWith(status: AttachmentsStatus.uploading)); + try { + final List> uploadTasks = []; + + // 1. Gestione Documenti normali (PlatformFile) + if (event.pickedFiles != null) { + for (var file in event.pickedFiles!) { + uploadTasks.add( + _repository.uploadAndRegisterFile( + parentId: state.parentId!, + parentType: state.parentType, + pickedFile: file, + ), + ); + } + } + + // 2. Gestione Foto Fotocamera (XFile) + if (event.photos != null) { + for (var photo in event.photos!) { + // Leggiamo i byte asincronamente + final bytes = await photo.readAsBytes(); + final fileSize = await photo.length(); + + // Lo travestiamo da PlatformFile per passarlo al Repository! + final fakePlatformFile = PlatformFile( + name: photo.name, + size: fileSize, + bytes: bytes, + path: photo.path, + ); + + uploadTasks.add( + _repository.uploadAndRegisterFile( + parentId: state.parentId!, + parentType: state.parentType, + pickedFile: fakePlatformFile, + ), + ); + } + } + + // Esecuzione parallela di tutti i documenti e foto + await Future.wait(uploadTasks); + emit(state.copyWith(status: AttachmentsStatus.success)); + } catch (e) { + emit( + state.copyWith(status: AttachmentsStatus.failure, error: e.toString()), + ); + } + } + + FutureOr _onDeleteAttachments( + DeleteAttachmentsEvent event, + Emitter emit, + ) async { + emit(state.copyWith(status: AttachmentsStatus.loading)); + try { + await _repository.deleteFiles( + files: state.selectedFiles, + currentContextType: state.parentType, + ); + emit( + state.copyWith(status: AttachmentsStatus.success, selectedFiles: []), + ); + } catch (e) { + emit( + state.copyWith(status: AttachmentsStatus.failure, error: e.toString()), + ); + } + } + + FutureOr _onToggleAttachmentSelection( + ToggleAttachmentSelectionEvent event, + Emitter emit, + ) { + final selectedFiles = List.from(state.selectedFiles); + if (selectedFiles.contains(event.file)) { + selectedFiles.remove(event.file); + } else { + selectedFiles.add(event.file); + } + emit(state.copyWith(selectedFiles: selectedFiles)); + } + + void _onSelectAllAttachments( + SelectAllAttachmentsEvent event, + Emitter emit, + ) { + // Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati + emit(state.copyWith(selectedFiles: state.allFiles)); + } + + void _onClearAttachmentSelection( + ClearAttachmentSelectionEvent event, + Emitter emit, + ) { + // Svuotiamo brutalmente la lista + emit(state.copyWith(selectedFiles: [])); + } + + FutureOr _onLinkAttachmentsToEntity( + LinkAttachmentsToEntityEvent event, + Emitter emit, + ) async { + if (state.selectedFiles.isEmpty) return; + + // BIVIO 1: PRATICA/TICKET NON ANCORA SALVATA (Modalità Locale) + if (state.parentId == null) { + final updatedLocalFiles = state.localFiles.map((file) { + if (state.selectedFiles.contains(file)) { + // Assegniamo dinamicamente l'ID in base all'entità scelta + switch (event.targetType) { + case AttachmentParentType.customer: + return file.copyWith(customerId: event.targetId); + case AttachmentParentType.ticket: + return file.copyWith(ticketId: event.targetId); + case AttachmentParentType.operation: + return file.copyWith(operationId: event.targetId); + } + } + return file; + }).toList(); + + emit( + state.copyWith( + localFiles: updatedLocalFiles, + selectedFiles: [], // Svuotiamo la selezione + status: AttachmentsStatus.success, + ), + ); + return; + } + + // BIVIO 2: PRATICA/TICKET ESISTENTE (Modalità Remota su DB) + emit(state.copyWith(status: AttachmentsStatus.loading)); + try { + final List> linkTasks = []; + + for (var file in state.selectedFiles) { + if (file.id != null) { + linkTasks.add( + _repository.linkFileToEntity( + fileId: file.id!, + targetType: event.targetType, + targetId: event.targetId, + ), + ); + } + } + + await Future.wait(linkTasks); + + // Lo stream aggiornerà automaticamente la UI + emit( + state.copyWith(status: AttachmentsStatus.success, selectedFiles: []), + ); + } catch (e) { + emit( + state.copyWith( + status: AttachmentsStatus.failure, + error: "Errore durante il collegamento: $e", + ), + ); + } + } + + FutureOr _onRenameAttachment( + RenameAttachmentEvent event, + Emitter emit, + ) async { + // BIVIO 1: File Locale (Bozza) + if (event.file.localBytes != null) { + final updatedLocalFiles = state.localFiles.map((f) { + if (f == event.file) { + return f.copyWith(name: event.newName); + } + return f; + }).toList(); + emit(state.copyWith(localFiles: updatedLocalFiles)); + return; + } + + // BIVIO 2: File Remoto (Salvato su DB) + emit(state.copyWith(status: AttachmentsStatus.loading)); + try { + await _repository.renameAttachment(event.file.id!, event.newName); + emit(state.copyWith(status: AttachmentsStatus.success)); + } catch (e) { + emit( + state.copyWith( + status: AttachmentsStatus.failure, + error: "Errore rinomina: $e", + ), + ); + } + } + + FutureOr _onDeleteSpecificAttachment( + DeleteSpecificAttachmentEvent event, + Emitter emit, + ) { + if (event.file.localBytes != null) { + final updatedLocalFiles = state.localFiles + .where((f) => f != event.file) + .toList(); + emit(state.copyWith(localFiles: updatedLocalFiles)); + } + } +} diff --git a/lib/features/attachments/blocs/attachments_events.dart b/lib/features/attachments/blocs/attachments_events.dart new file mode 100644 index 0000000..f968e63 --- /dev/null +++ b/lib/features/attachments/blocs/attachments_events.dart @@ -0,0 +1,68 @@ +part of 'attachments_bloc.dart'; + +abstract class AttachmentsEvent extends Equatable { + const AttachmentsEvent(); + + @override + List get props => []; +} + +/// Chiamato quando l'entità "padre" (es. il Ticket) viene salvata per la prima volta +class ParentEntitySavedEvent extends AttachmentsEvent { + final String newParentId; + const ParentEntitySavedEvent(this.newParentId); + + @override + List get props => [newParentId]; +} + +class LoadAttachmentsEvent extends AttachmentsEvent { + final String? parentId; + const LoadAttachmentsEvent({this.parentId}); +} + +class AddAttachmentsEvent extends AttachmentsEvent { + final List files; + const AddAttachmentsEvent(this.files); +} + +class UploadAttachmentsEvent extends AttachmentsEvent { + final List? pickedFiles; + final List? photos; + const UploadAttachmentsEvent({this.pickedFiles, this.photos}); +} + +class DeleteAttachmentsEvent extends AttachmentsEvent {} + +class ToggleAttachmentSelectionEvent extends AttachmentsEvent { + final AttachmentModel file; + const ToggleAttachmentSelectionEvent(this.file); +} + +class SelectAllAttachmentsEvent extends AttachmentsEvent {} + +class ClearAttachmentSelectionEvent extends AttachmentsEvent {} + +class LinkAttachmentsToEntityEvent extends AttachmentsEvent { + final AttachmentParentType targetType; + final String targetId; + + const LinkAttachmentsToEntityEvent({ + required this.targetType, + required this.targetId, + }); + + @override + List get props => [targetType, targetId]; +} + +class RenameAttachmentEvent extends AttachmentsEvent { + final AttachmentModel file; + final String newName; + const RenameAttachmentEvent(this.file, this.newName); +} + +class DeleteSpecificAttachmentEvent extends AttachmentsEvent { + final AttachmentModel file; + const DeleteSpecificAttachmentEvent(this.file); +} diff --git a/lib/features/attachments/blocs/operation_files_state.dart b/lib/features/attachments/blocs/attachments_state.dart similarity index 53% rename from lib/features/attachments/blocs/operation_files_state.dart rename to lib/features/attachments/blocs/attachments_state.dart index a102dd4..94de53c 100644 --- a/lib/features/attachments/blocs/operation_files_state.dart +++ b/lib/features/attachments/blocs/attachments_state.dart @@ -1,10 +1,28 @@ -part of 'operation_files_bloc.dart'; +part of 'attachments_bloc.dart'; -enum OperationFilesStatus { initial, loading, uploading, success, failure } +enum AttachmentsStatus { initial, loading, uploading, success, failure } -class OperationFilesState extends Equatable { - const OperationFilesState({ - this.operationId, +enum AttachmentParentType { + operation('operation_id'), + ticket('ticket_id'), + customer('customer_id'); + + final String dbColumn; + const AttachmentParentType(this.dbColumn); +} + +class AttachmentsState extends Equatable { + final String? parentId; + final AttachmentParentType parentType; + final AttachmentsStatus status; + final String? error; + final List localFiles; + final List remoteFiles; + final List selectedFiles; + + const AttachmentsState({ + this.parentId, + required this.parentType, required this.status, this.error, this.localFiles = const [], @@ -12,17 +30,10 @@ class OperationFilesState extends Equatable { this.selectedFiles = const [], }); - final String? operationId; - final OperationFilesStatus status; - final String? error; - final List localFiles; - final List remoteFiles; - - final List selectedFiles; - @override List get props => [ - operationId, + parentId, + parentType, status, error, localFiles, @@ -32,16 +43,18 @@ class OperationFilesState extends Equatable { List get allFiles => [...remoteFiles, ...localFiles]; - OperationFilesState copyWith({ - String? operationId, - OperationFilesStatus? status, + AttachmentsState copyWith({ + String? parentId, + AttachmentParentType? parentType, + AttachmentsStatus? status, String? error, List? localFiles, List? remoteFiles, List? selectedFiles, }) { - return OperationFilesState( - operationId: operationId ?? this.operationId, + return AttachmentsState( + parentId: parentId ?? this.parentId, + parentType: parentType ?? this.parentType, status: status ?? this.status, error: error, localFiles: localFiles ?? this.localFiles, diff --git a/lib/features/attachments/blocs/operation_files_bloc.dart b/lib/features/attachments/blocs/operation_files_bloc.dart deleted file mode 100644 index 7a48387..0000000 --- a/lib/features/attachments/blocs/operation_files_bloc.dart +++ /dev/null @@ -1,389 +0,0 @@ -import 'dart:async'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flux/core/blocs/session/session_cubit.dart'; -import 'package:flux/core/utils/extensions.dart'; -import 'package:flux/features/attachments/models/attachment_model.dart'; -import 'package:flux/features/operations/data/operations_repository.dart'; -import 'package:get_it/get_it.dart'; -import 'package:image_picker/image_picker.dart'; - -part 'operation_files_events.dart'; -part 'operation_files_state.dart'; - -class OperationFilesBloc - extends Bloc { - final _repository = GetIt.I.get(); - final String? operationId; - - OperationFilesBloc({this.operationId}) - : super( - OperationFilesState( - status: OperationFilesStatus.initial, - operationId: operationId, - ), - ) { - on(_onOperationsaved); - on(_onLoadOperationFiles); - on(_onAddOperationFiles); - on(_onUploadOperationFiles); - on(_onDeleteOperationFiles); - on(_onToggleOperationFileSelection); - on(_onLinkFilesToCustomer); - on(_onRenameOperationFile); - on(_onDeleteSpecificOperationFiles); - on(_onSelectAllOperationFiles); - on(_onClearOperationFileSelection); - - // Se il BLoC nasce con un ID, accendiamo subito lo stream! - if (operationId != null) { - add(LoadOperationFilesEvent(operationId: operationId)); - } - } - - FutureOr _onOperationsaved( - OperationsavedEvent event, - Emitter 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> 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 _onLoadOperationFiles( - LoadOperationFilesEvent event, - Emitter 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 data) => state.copyWith( - status: OperationFilesStatus.success, - remoteFiles: data, - ), - onError: (error, stackTrace) => state.copyWith( - status: OperationFilesStatus.failure, - error: error.toString(), - ), - ); - } - } - - void _onAddOperationFiles( - AddOperationFilesEvent event, - Emitter emit, - ) async { - final currentId = state.operationId; - - // BIVIO 1: PRATICA NUOVA (Nessun ID - salvataggio locale) - if (currentId == null) { - final companyId = GetIt.I.get().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> 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 _onUploadOperationFiles( - UploadOperationFilesEvent event, - Emitter 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> 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 _onDeleteOperationFiles( - DeleteOperationFilesEvent event, - Emitter 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 _onToggleOperationFileSelection( - ToggleOperationFileSelectionEvent event, - Emitter emit, - ) { - final selectedFiles = List.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 emit, - ) { - // Prendiamo TUTTI i file (locali e remoti) e li buttiamo nei selezionati - emit(state.copyWith(selectedFiles: state.allFiles)); - } - - void _onClearOperationFileSelection( - ClearOperationFileSelectionEvent event, - Emitter emit, - ) { - // Svuotiamo brutalmente la lista - emit(state.copyWith(selectedFiles: [])); - } - - FutureOr _onLinkFilesToCustomer( - LinkFilesToCustomerEvent event, - Emitter 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> 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 _onRenameOperationFile( - RenameOperationFileEvent event, - Emitter 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 _onDeleteSpecificOperationFiles( - DeleteSpecificOperationFileEvent event, - Emitter emit, - ) { - if (event.file.localBytes != null) { - final updatedLocalFiles = state.localFiles - .where((f) => f != event.file) - .toList(); - emit(state.copyWith(localFiles: updatedLocalFiles)); - } - } -} diff --git a/lib/features/attachments/blocs/operation_files_events.dart b/lib/features/attachments/blocs/operation_files_events.dart deleted file mode 100644 index f80dfce..0000000 --- a/lib/features/attachments/blocs/operation_files_events.dart +++ /dev/null @@ -1,81 +0,0 @@ -part of 'operation_files_bloc.dart'; - -abstract class OperationFilesEvent extends Equatable { - const OperationFilesEvent(); - - @override - List get props => []; -} - -class OperationsavedEvent extends OperationFilesEvent { - final String operationId; - const OperationsavedEvent(this.operationId); - - @override - List get props => [operationId]; -} - -class LoadOperationFilesEvent extends OperationFilesEvent { - final String? operationId; - final AttachmentModel? operation; - const LoadOperationFilesEvent({this.operationId, this.operation}); - - @override - List get props => [operationId, operation]; -} - -class AddOperationFilesEvent extends OperationFilesEvent { - final List files; - const AddOperationFilesEvent(this.files); - - @override - List get props => [files]; -} - -class UploadOperationFilesEvent extends OperationFilesEvent { - final List? pickedFiles; - final List? photos; - const UploadOperationFilesEvent({this.pickedFiles, this.photos}); - - @override - List get props => [pickedFiles, photos]; -} - -class LinkFilesToCustomerEvent extends OperationFilesEvent { - final String customerId; - - const LinkFilesToCustomerEvent({required this.customerId}); - - @override - List 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 get props => [file, newName]; -} - -class DeleteSpecificOperationFileEvent extends OperationFilesEvent { - final AttachmentModel file; - - const DeleteSpecificOperationFileEvent(this.file); - - @override - List get props => [file]; -} - -class SelectAllOperationFilesEvent extends OperationFilesEvent {} - -class ClearOperationFileSelectionEvent extends OperationFilesEvent {} diff --git a/lib/features/attachments/data/attachments_repository.dart b/lib/features/attachments/data/attachments_repository.dart index a7760b3..573987d 100644 --- a/lib/features/attachments/data/attachments_repository.dart +++ b/lib/features/attachments/data/attachments_repository.dart @@ -1,23 +1,198 @@ import 'dart:typed_data'; - +import 'package:file_picker/file_picker.dart'; +import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; +import 'package:get_it/get_it.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; class AttachmentsRepository { final _supabase = Supabase.instance.client; + static const String _bucketName = 'attachments'; + static const String _tableName = + 'attachment'; // Cambia col vero nome della tua tabella se diverso! /// Scarica i byte di un file direttamente da Supabase Storage Future downloadAttachmentBytes(String storagePath) async { try { - // ATTENZIONE: Sostituisci 'attachments' con il nome VERO del tuo bucket su Supabase! - // Se il tuo storagePath contiene già il nome del bucket all'inizio, - // assicurati di passargli solo il percorso interno. final Uint8List bytes = await _supabase.storage - .from('attachments') // <--- NOME DEL TUO BUCKET + .from(_bucketName) .download(storagePath); - return bytes; } catch (e) { throw Exception("Impossibile scaricare il documento dal cloud: $e"); } } + + /// RESTITUISCE IL NOME DELLA COLONNA DB IN BASE AL TIPO + String _getColumnNameForParent(AttachmentParentType parentType) { + switch (parentType) { + case AttachmentParentType.operation: + return 'operation_id'; + case AttachmentParentType.ticket: + return 'ticket_id'; + case AttachmentParentType.customer: + return 'customer_id'; + } + } + + /// RECUPERA I FILE IN TEMPO REALE + Stream> getFilesStream( + String parentId, + AttachmentParentType parentType, + ) { + final columnName = _getColumnNameForParent(parentType); + + return _supabase + .from(_tableName) + .stream(primaryKey: ['id']) + .eq(columnName, parentId) + .map( + (listOfMaps) => + listOfMaps.map((map) => AttachmentModel.fromMap(map)).toList(), + ); + } + + /// CARICA IL FILE NELLO STORAGE E LO REGISTRA NEL DB + Future uploadAndRegisterFile({ + required String parentId, + required AttachmentParentType parentType, + required PlatformFile pickedFile, + }) async { + try { + final companyId = GetIt.I.get().state.company!.id!; + final extension = pickedFile.extension ?? pickedFile.name.split('.').last; + final cleanName = pickedFile.name + .replaceAll(RegExp(r'[^\w\s\.-]'), '') + .replaceAll(' ', '_'); + + // Creiamo un path ordinato: idAzienda/tipoEntita/idEntita/timestamp_nomefile + final timestamp = DateTime.now().millisecondsSinceEpoch; + final storagePath = + '$companyId/${parentType.name}/$parentId/${timestamp}_$cleanName'; + + // 1. Upload su Supabase Storage + await _supabase.storage + .from(_bucketName) + .uploadBinary( + storagePath, + pickedFile.bytes!, + fileOptions: FileOptions( + upsert: true, + contentType: _guessContentType(extension), + ), + ); + + // 2. Creiamo la mappa per il DB dinamicamente + final Map insertData = { + 'company_id': companyId, + 'name': pickedFile.name.replaceAll('.$extension', ''), + 'extension': extension, + 'file_size': pickedFile.size, + 'storage_path': storagePath, + }; + + // Inseriamo l'ID nella colonna giusta! + final columnName = _getColumnNameForParent(parentType); + insertData[columnName] = parentId; + + // 3. Salviamo su Postgres + await _supabase.from(_tableName).insert(insertData); + } catch (e) { + throw Exception("Errore nel caricamento del file: $e"); + } + } + + /// ELIMINA IL FILE (Scollegamento intelligente) + Future deleteFiles({ + required List files, + required AttachmentParentType currentContextType, + }) async { + if (files.isEmpty) return; + + try { + for (var file in files) { + if (file.id == null) continue; + + // 1. Capiamo quali collegamenti ha questo file attualmente + final currentLinks = { + AttachmentParentType.operation: file.operationId, + AttachmentParentType.ticket: file.ticketId, + AttachmentParentType.customer: file.customerId, + }; + + // 2. Simuliamo la rimozione del collegamento per il contesto attuale + currentLinks[currentContextType] = null; + + // 3. Controlliamo se rimangono altri ID valorizzati + final hasOtherActiveLinks = currentLinks.values.any( + (id) => id != null && id.isNotEmpty, + ); + + if (hasOtherActiveLinks) { + // A. Ci sono ancora altre entità che usano questo file! + // Scolleghiamolo SOLO dal contesto attuale mettendo a NULL la sua colonna + await _supabase + .from(_tableName) + .update({currentContextType.dbColumn: null}) + .eq('id', file.id!); + } else { + // B. Nessuno usa più questo file! ELIMINAZIONE FISICA TOTALE. + await _supabase.from(_tableName).delete().eq('id', file.id!); + + if (file.storagePath != null) { + await _supabase.storage.from(_bucketName).remove([ + file.storagePath!, + ]); + } + } + } + } catch (e) { + throw Exception("Errore nell'eliminazione dei file: $e"); + } + } + + /// RINOMINA UN FILE (Solo nel DB, non cambiamo il file fisico) + Future renameAttachment(String fileId, String newName) async { + try { + await _supabase + .from(_tableName) + .update({'name': newName}) + .eq('id', fileId); + } catch (e) { + throw Exception("Errore nella rinomina del file: $e"); + } + } + + /// ASSOCIA UN FILE A UN'ALTRA ENTITÀ (Modifica il record esistente) + Future linkFileToEntity({ + required String fileId, + required AttachmentParentType targetType, + required String targetId, + }) async { + try { + // Facciamo un semplice UPDATE aggiungendo l'ID nella colonna giusta + await _supabase + .from(_tableName) + .update({targetType.dbColumn: targetId}) + .eq('id', fileId); + } catch (e) { + throw Exception("Errore nel collegamento del file: $e"); + } + } + + // Helper per indovinare il content-type base + String _guessContentType(String extension) { + switch (extension.toLowerCase()) { + case 'pdf': + return 'application/pdf'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + return 'application/octet-stream'; + } + } } diff --git a/lib/features/attachments/models/attachment_model.dart b/lib/features/attachments/models/attachment_model.dart index 8e61b3e..a1e0eb2 100644 --- a/lib/features/attachments/models/attachment_model.dart +++ b/lib/features/attachments/models/attachment_model.dart @@ -7,6 +7,7 @@ class AttachmentModel extends Equatable { final DateTime? createdAt; final String? customerId; final String? operationId; + final String? ticketId; final String name; final String extension; final String? storagePath; @@ -19,6 +20,7 @@ class AttachmentModel extends Equatable { this.createdAt, this.customerId, this.operationId, + this.ticketId, required this.name, required this.extension, this.storagePath, @@ -33,6 +35,7 @@ class AttachmentModel extends Equatable { createdAt, customerId, operationId, + ticketId, name, extension, storagePath, @@ -59,6 +62,7 @@ class AttachmentModel extends Equatable { DateTime? createdAt, String? customerId, String? operationId, + String? ticketId, String? name, String? extension, String? storagePath, @@ -70,6 +74,7 @@ class AttachmentModel extends Equatable { createdAt: createdAt ?? this.createdAt, customerId: customerId ?? this.customerId, operationId: operationId ?? this.operationId, + ticketId: ticketId ?? this.ticketId, name: name ?? this.name, extension: extension ?? this.extension, storagePath: storagePath ?? this.storagePath, @@ -86,6 +91,7 @@ class AttachmentModel extends Equatable { : null, customerId: map['customer_id'] as String?, operationId: map['operation_id'] as String?, + ticketId: map['ticket_id'] as String?, name: map['name'] as String, extension: map['extension'] as String, storagePath: map['storage_path'] as String?, @@ -104,6 +110,7 @@ class AttachmentModel extends Equatable { 'storage_path': storagePath, 'customer_id': customerId, 'operation_id': operationId, + 'ticket_id': ticketId, 'file_size': fileSize, 'company_id': companyId, }; diff --git a/lib/features/operations/ui/operation_mobile_upload_screen.dart b/lib/features/operations/ui/operation_mobile_upload_screen.dart index ad916c7..2ff950c 100644 --- a/lib/features/operations/ui/operation_mobile_upload_screen.dart +++ b/lib/features/operations/ui/operation_mobile_upload_screen.dart @@ -1,7 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/attachments/blocs/operation_files_bloc.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; @@ -36,10 +36,10 @@ class _OperationMobileUploadScreenState @override Widget build(BuildContext context) { - return BlocListener( + return BlocListener( listener: (context, state) { // Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina! - if (state.status == OperationFilesStatus.success && _isUploading) { + if (state.status == AttachmentsStatus.success && _isUploading) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text("Tutti i file caricati con successo! ✅"), @@ -47,7 +47,7 @@ class _OperationMobileUploadScreenState ); Navigator.of(context).pop(); } - if (state.status == OperationFilesStatus.failure) { + if (state.status == AttachmentsStatus.failure) { setState(() => _isUploading = false); ScaffoldMessenger.of( context, @@ -295,8 +295,8 @@ class _OperationMobileUploadScreenState // Diciamo al BLoC di caricare tutti i file. // Usiamo il tuo evento esistente per ogni file (il BLoC li metterà in coda) - final bloc = context.read(); - bloc.add(UploadOperationFilesEvent(pickedFiles: _stagedFiles)); + final bloc = context.read(); + bloc.add(UploadAttachmentsEvent(pickedFiles: _stagedFiles)); // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! } -- 2.43.0 From 1e9e6947b38fe310ae7d3c9fef1dd12c8e66e593 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 11:33:28 +0200 Subject: [PATCH 06/13] pulizie completate --- lib/core/routes/app_router.dart | 24 +- .../shared_mobile_upload_screen.dart} | 59 ++-- .../shared_forms/shared_model_section.dart | 165 ++++++++++ .../customers/blocs/customer_files_bloc.dart | 139 -------- .../blocs/customer_files_events.dart | 30 -- .../customers/blocs/customer_files_state.dart | 34 -- .../customers/ui/customer_detail_screen.dart | 42 ++- .../ui/customer_mobile_upload_screen.dart | 304 ------------------ .../ui/widgets/details_section.dart | 160 +-------- 9 files changed, 238 insertions(+), 719 deletions(-) rename lib/{features/operations/ui/operation_mobile_upload_screen.dart => core/widgets/shared_forms/shared_mobile_upload_screen.dart} (86%) create mode 100644 lib/core/widgets/shared_forms/shared_model_section.dart delete mode 100644 lib/features/customers/blocs/customer_files_bloc.dart delete mode 100644 lib/features/customers/blocs/customer_files_events.dart delete mode 100644 lib/features/customers/blocs/customer_files_state.dart delete mode 100644 lib/features/customers/ui/customer_mobile_upload_screen.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 0160e15..ff8160b 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -6,12 +6,11 @@ import 'package:flux/core/data/core_repository.dart'; import 'package:flux/core/layout/app_shell.dart'; import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/widgets/set_password_screen.dart'; +import 'package:flux/core/widgets/shared_forms/shared_mobile_upload_screen.dart'; import 'package:flux/features/auth/ui/auth_screen.dart'; -import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/customers/blocs/customers_cubit.dart'; import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/customer_detail_screen.dart'; -import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart'; import 'package:flux/features/customers/ui/customers_content.dart'; import 'package:flux/features/home/ui/home_screen.dart'; import 'package:flux/features/master_data/master_data_hub_content.dart'; @@ -27,7 +26,6 @@ import 'package:flux/features/onboarding/ui/onboarding_screen.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart'; -import 'package:flux/features/operations/ui/operation_mobile_upload_screen.dart'; import 'package:flux/features/operations/ui/operations_screen.dart'; import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart'; import 'package:flux/features/tickets/ui/ticket_list_screen.dart'; @@ -164,7 +162,10 @@ class AppRouter { builder: (context, state) { final customer = state.extra as CustomerModel; return BlocProvider( - create: (context) => CustomerFilesBloc(customer.id!), + create: (context) => AttachmentsBloc( + parentType: AttachmentParentType.customer, + parentId: customer.id, + ), child: CustomerDetailScreen(customer: customer), ); }, @@ -175,10 +176,12 @@ class AppRouter { final customerId = state.pathParameters['id']!; final customerName = state.uri.queryParameters['name'] ?? 'Cliente'; return BlocProvider( - create: (context) => CustomerFilesBloc(customerId), - child: CustomerMobileUploadScreen( - customerId: customerId, - customerName: customerName, + create: (context) => AttachmentsBloc( + parentType: AttachmentParentType.customer, + parentId: customerId, + ), + child: SharedMobileUploadScreen( + title: 'Aggiungi allegati al cliente $customerName', ), ); }, @@ -237,9 +240,8 @@ class AppRouter { parentId: operationId, parentType: AttachmentParentType.operation, ), - child: OperationMobileUploadScreen( - operationId: operationId, - operationName: operationName, + child: SharedMobileUploadScreen( + title: 'Aggiungi allegati alla pratica $operationName', ), ); }, diff --git a/lib/features/operations/ui/operation_mobile_upload_screen.dart b/lib/core/widgets/shared_forms/shared_mobile_upload_screen.dart similarity index 86% rename from lib/features/operations/ui/operation_mobile_upload_screen.dart rename to lib/core/widgets/shared_forms/shared_mobile_upload_screen.dart index 2ff950c..e1c6b3d 100644 --- a/lib/features/operations/ui/operation_mobile_upload_screen.dart +++ b/lib/core/widgets/shared_forms/shared_mobile_upload_screen.dart @@ -1,27 +1,21 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; -class OperationMobileUploadScreen extends StatefulWidget { - final String operationId; - final String operationName; +class SharedMobileUploadScreen extends StatefulWidget { + final String title; - const OperationMobileUploadScreen({ - super.key, - required this.operationId, - required this.operationName, - }); + const SharedMobileUploadScreen({super.key, required this.title}); @override - State createState() => - _OperationMobileUploadScreenState(); + State createState() => + _SharedMobileUploadScreenState(); } -class _OperationMobileUploadScreenState - extends State { +class _SharedMobileUploadScreenState extends State { // 1. LA NOSTRA STAGING AREA (Il "Carrello") final List _stagedFiles = []; @@ -56,7 +50,8 @@ class _OperationMobileUploadScreenState }, child: Scaffold( appBar: AppBar( - title: Text("Upload Pratica:\n${widget.operationName}"), + title: Text("Upload: ${widget.title}"), + // Togliamo la freccia indietro se stiamo caricando per evitare disastri automaticallyImplyLeading: !_isUploading, ), body: Stack( @@ -109,8 +104,7 @@ class _OperationMobileUploadScreenState padding: const EdgeInsets.all(16), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - 3, // 3 colonne come la galleria dell'iPhone + crossAxisCount: 3, // 3 colonne stile galleria crossAxisSpacing: 12, mainAxisSpacing: 12, ), @@ -136,10 +130,17 @@ class _OperationMobileUploadScreenState child: ClipRRect( borderRadius: BorderRadius.circular(12), child: isImg - ? Image.file( - File(file.path!), - fit: BoxFit.cover, - ) + ? (file.bytes != null + // Se abbiamo i bytes (es. scatto da fotocamera) usiamo quelli (a prova di Web!) + ? Image.memory( + file.bytes!, + fit: BoxFit.cover, + ) + // Altrimenti andiamo di file fisico + : Image.file( + File(file.path!), + fit: BoxFit.cover, + )) : const Column( mainAxisAlignment: MainAxisAlignment.center, @@ -227,9 +228,10 @@ class _OperationMobileUploadScreenState ], ), - // --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) --- + // --- OVERLAY DI CARICAMENTO --- if (_isUploading) Container( + // Usa il metodo non deprecato che hai giustamente suggerito! color: Colors.black.withValues(alpha: 0.5), child: const Center( child: Card( @@ -264,7 +266,7 @@ class _OperationMobileUploadScreenState imageQuality: 80, ); if (photo != null) { - final photoBytes = await photo.readAsBytes(); // Sicuro anche per Web! + final photoBytes = await photo.readAsBytes(); final photoSize = await photo.length(); final platformFile = PlatformFile( @@ -274,13 +276,12 @@ class _OperationMobileUploadScreenState bytes: photoBytes, // I bytes ci salvano la vita su Supabase! ); setState(() { - _stagedFiles.add(platformFile); // Unifichiamo tutto in un dart:io File + _stagedFiles.add(platformFile); }); } } Future _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(() { @@ -293,11 +294,9 @@ class _OperationMobileUploadScreenState 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(); - bloc.add(UploadAttachmentsEvent(pickedFiles: _stagedFiles)); - - // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! + // Lanciamo l'evento del nostro nuovo AttachmentsBloc Agnostico! + context.read().add( + UploadAttachmentsEvent(pickedFiles: _stagedFiles), + ); } } diff --git a/lib/core/widgets/shared_forms/shared_model_section.dart b/lib/core/widgets/shared_forms/shared_model_section.dart new file mode 100644 index 0000000..1bdc70f --- /dev/null +++ b/lib/core/widgets/shared_forms/shared_model_section.dart @@ -0,0 +1,165 @@ +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'; + +class SharedModelSection extends StatelessWidget { + final String? modelId; + final String? modelName; + final String label; + + // Usiamo una callback che passa direttamente ID e Nome + // così non dobbiamo preoccuparci di importare la classe esatta del modello ovunque + final void Function(String id, String name) onModelSelected; + + const SharedModelSection({ + super.key, + required this.modelId, + required this.modelName, + required this.onModelSelected, + this.label = 'Seleziona Modello', + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasModel = modelId != null && modelId!.isNotEmpty; + + return ListTile( + title: Text(label), + subtitle: Text( + hasModel ? modelName! : 'Nessun modello selezionato', + style: TextStyle( + color: hasModel ? null : Colors.grey, + fontWeight: hasModel ? FontWeight.bold : FontWeight.normal, + ), + ), + trailing: const Icon(Icons.arrow_drop_down), + shape: RoundedRectangleBorder( + side: BorderSide(color: theme.dividerColor), + borderRadius: BorderRadius.circular(8), + ), + onTap: () => _showModelModal(context), + ); + } + + 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().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 { + // Leggiamo i brand dal Cubit per passarli alla dialog + final existingBrands = context + .read() + .state + .brands; + + final newModel = await showDialog( + context: context, + builder: (dialogContext) { + return BlocProvider.value( + value: context.read(), + child: QuickProductDialog( + existingBrands: existingBrands, + ), + ); + }, + ); + + if (newModel != null) { + // CHIAMIAMO LA CALLBACK! + onModelSelected(newModel.id, newModel.nameWithBrand); + if (context.mounted) Navigator.pop(modalContext); + } + }, + ), + ), + const Divider(), + Expanded( + child: BlocBuilder( + 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: () { + // CHIAMIAMO LA CALLBACK! + onModelSelected( + deviceModel.id!, + deviceModel.nameWithBrand, + ); + Navigator.pop(modalContext); + }, + ); + }, + ); + }, + ), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/features/customers/blocs/customer_files_bloc.dart b/lib/features/customers/blocs/customer_files_bloc.dart deleted file mode 100644 index c8662e0..0000000 --- a/lib/features/customers/blocs/customer_files_bloc.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flux/features/attachments/models/attachment_model.dart'; -import 'package:flux/features/customers/data/customer_repository.dart'; -import 'package:get_it/get_it.dart'; - -part 'customer_files_events.dart'; -part 'customer_files_state.dart'; - -class CustomerFilesBloc extends Bloc { - final CustomerRepository _repository = GetIt.I(); - final String customerId; - CustomerFilesBloc(this.customerId) - : super(const CustomerFilesState(status: CustomerFilesStatus.initial)) { - on(_loadCustomerFiles); - on(_uploadCustomerFile); - on(_uploadMultipleCustomerFiles); - on(_deleteCustomerFiles); - on(_toggleCustomerFileSelection); - } - void _loadCustomerFiles( - LoadCustomerFilesEvent event, - Emitter emit, - ) async { - await emit.forEach>( - _repository.getCustomerFilesStream(customerId), - onData: (customerFiles) => CustomerFilesState( - status: CustomerFilesStatus.success, - customerFiles: customerFiles, - ), - onError: (error, stackTrace) => CustomerFilesState( - status: CustomerFilesStatus.failure, - error: error.toString(), - ), - ); - } - - Future _uploadCustomerFile( - UploadCustomerFileEvent event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerFilesStatus.uploading)); - if (event.pickedFile != null) { - try { - await _repository.uploadAndRegisterFile( - customerId: customerId, - pickedFile: event.pickedFile!, - ); - emit(state.copyWith(status: CustomerFilesStatus.success)); - } catch (e) { - emit( - state.copyWith( - status: CustomerFilesStatus.failure, - error: e.toString(), - ), - ); - } - } - } - - FutureOr _uploadMultipleCustomerFiles( - UploadMultipleCustomerFilesEvent event, - Emitter emit, - ) async { - if (event.files.isEmpty) { - emit( - state.copyWith( - status: CustomerFilesStatus.failure, - error: "Nessun file selezionato", - ), - ); - return; - } - emit(state.copyWith(status: CustomerFilesStatus.uploading, error: null)); - try { - // 2. Creiamo una lista di "Promesse" (Futures) per il repository - final List> uploadTasks = []; - for (var file in event.files) { - // Aggiungiamo il task alla lista, ma NON usiamo await qui dentro! - uploadTasks.add( - _repository.uploadAndRegisterFile( - customerId: customerId, - pickedFile: file, - ), - ); - } - // 3. ESECUZIONE PARALLELA! - // Aspettiamo che tutti i file siano caricati contemporaneamente. - await Future.wait(uploadTasks); - // 4. GRAN FINALE: Tutto caricato, emettiamo il success! - emit(state.copyWith(status: CustomerFilesStatus.success)); - } catch (e) { - // Se anche un solo file fallisce, catturiamo l'errore - emit( - state.copyWith( - status: CustomerFilesStatus.failure, - error: "Errore durante l'upload multiplo: $e", - ), - ); - } - } - - Future _deleteCustomerFiles( - DeleteCustomerFilesEvent event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerFilesStatus.loading)); - try { - await _repository.deleteDocuments(state.selectedFiles); - emit( - state.copyWith(status: CustomerFilesStatus.success, selectedFiles: []), - ); - } catch (e) { - emit( - state.copyWith( - status: CustomerFilesStatus.failure, - error: e.toString(), - ), - ); - } - } - - void _toggleCustomerFileSelection( - ToggleCustomerFileSelectionEvent event, - Emitter emit, - ) { - List selectedFiles = List.from(state.selectedFiles); - if (selectedFiles.contains(event.file)) { - selectedFiles.remove(event.file); - } else { - selectedFiles.add(event.file); - } - emit(state.copyWith(selectedFiles: selectedFiles)); - } -} diff --git a/lib/features/customers/blocs/customer_files_events.dart b/lib/features/customers/blocs/customer_files_events.dart deleted file mode 100644 index b893ce8..0000000 --- a/lib/features/customers/blocs/customer_files_events.dart +++ /dev/null @@ -1,30 +0,0 @@ -part of 'customer_files_bloc.dart'; - -abstract class CustomerFilesEvent extends Equatable { - const CustomerFilesEvent(); - - @override - List get props => []; -} - -class LoadCustomerFilesEvent extends CustomerFilesEvent {} - -class UploadCustomerFileEvent extends CustomerFilesEvent { - final PlatformFile? pickedFile; - final File? photo; - const UploadCustomerFileEvent({this.pickedFile, this.photo}); -} - -class UploadMultipleCustomerFilesEvent extends CustomerFilesEvent { - final List files; - const UploadMultipleCustomerFilesEvent(this.files); - @override - List get props => [files]; -} - -class DeleteCustomerFilesEvent extends CustomerFilesEvent {} - -class ToggleCustomerFileSelectionEvent extends CustomerFilesEvent { - final AttachmentModel file; - const ToggleCustomerFileSelectionEvent(this.file); -} diff --git a/lib/features/customers/blocs/customer_files_state.dart b/lib/features/customers/blocs/customer_files_state.dart deleted file mode 100644 index bdb525d..0000000 --- a/lib/features/customers/blocs/customer_files_state.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of 'customer_files_bloc.dart'; - -enum CustomerFilesStatus { initial, loading, uploading, success, failure } - -class CustomerFilesState extends Equatable { - const CustomerFilesState({ - required this.status, - this.error, - this.customerFiles = const [], - this.selectedFiles = const [], - }); - - final CustomerFilesStatus status; - final String? error; - final List customerFiles; - final List selectedFiles; - - @override - List get props => [status, error, customerFiles, selectedFiles]; - - CustomerFilesState copyWith({ - CustomerFilesStatus? status, - String? error, - List? customerFiles, - List? selectedFiles, - }) { - return CustomerFilesState( - status: status ?? this.status, - error: error, - customerFiles: customerFiles ?? this.customerFiles, - selectedFiles: selectedFiles ?? this.selectedFiles, - ); - } -} diff --git a/lib/features/customers/ui/customer_detail_screen.dart b/lib/features/customers/ui/customer_detail_screen.dart index b6e64da..30f90aa 100644 --- a/lib/features/customers/ui/customer_detail_screen.dart +++ b/lib/features/customers/ui/customer_detail_screen.dart @@ -6,8 +6,8 @@ import 'package:flux/core/theme/theme.dart'; import 'package:flux/core/widgets/image_viewer_widget.dart'; import 'package:flux/core/widgets/pdf_viewer_widget.dart'; import 'package:flux/core/widgets/qr_upload_dialog.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/attachments/models/attachment_model.dart'; -import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; import 'package:flux/features/customers/models/customer_model.dart'; class CustomerDetailScreen extends StatefulWidget { @@ -26,11 +26,13 @@ class _CustomerDetailScreenState extends State { } void _loadFiles() { - context.read().add(LoadCustomerFilesEvent()); + context.read().add( + LoadAttachmentsEvent(parentId: widget.customer.id), + ); } Future _pickAndUpload() async { - CustomerFilesBloc customerFilesBloc = context.read(); + AttachmentsBloc attachmentsBloc = context.read(); // Chiamata statica pulita FilePickerResult? result = await FilePicker.pickFiles( @@ -40,17 +42,13 @@ class _CustomerDetailScreenState extends State { ); if (result != null) { - for (var pickedFile in result.files) { - try { - customerFilesBloc.add( - UploadCustomerFileEvent(pickedFile: pickedFile), - ); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Errore upload ${pickedFile.name}: $e")), - ); - } + try { + attachmentsBloc.add(UploadAttachmentsEvent(pickedFiles: result.files)); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("$e"))); } } } @@ -143,7 +141,7 @@ class _CustomerDetailScreenState extends State { } Widget _buildDocumentSection() { - return BlocBuilder( + return BlocBuilder( builder: (context, state) { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -213,9 +211,9 @@ class _CustomerDetailScreenState extends State { ], ), const SizedBox(height: 20), - if (state.status == CustomerFilesStatus.loading) + if (state.status == AttachmentsStatus.loading) const Center(child: CircularProgressIndicator()) - else if (state.customerFiles.isEmpty) + else if (state.allFiles.isEmpty) const Center(child: Text("Nessun documento presente")) else Expanded( @@ -226,9 +224,9 @@ class _CustomerDetailScreenState extends State { crossAxisSpacing: 16, childAspectRatio: 1.2, ), - itemCount: state.customerFiles.length, + itemCount: state.allFiles.length, itemBuilder: (context, index) => - _FileCard(file: state.customerFiles[index], state: state), + _FileCard(file: state.allFiles[index], state: state), ), ), ], @@ -268,14 +266,14 @@ class _CustomerDetailScreenState extends State { class _FileCard extends StatelessWidget { final AttachmentModel file; - final CustomerFilesState state; + final AttachmentsState state; const _FileCard({required this.file, required this.state}); @override Widget build(BuildContext context) { return GestureDetector( - onTap: () => context.read().add( - ToggleCustomerFileSelectionEvent(file), + onTap: () => context.read().add( + ToggleAttachmentSelectionEvent(file), ), onDoubleTap: () => _handleDoubleClickOnFile(context, file), child: Stack( diff --git a/lib/features/customers/ui/customer_mobile_upload_screen.dart b/lib/features/customers/ui/customer_mobile_upload_screen.dart deleted file mode 100644 index 0f18079..0000000 --- a/lib/features/customers/ui/customer_mobile_upload_screen.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; - -class CustomerMobileUploadScreen extends StatefulWidget { - final String customerId; - final String customerName; - - const CustomerMobileUploadScreen({ - super.key, - required this.customerId, - required this.customerName, - }); - - @override - State createState() => - _CustomerMobileUploadScreenState(); -} - -class _CustomerMobileUploadScreenState - extends State { - // 1. LA NOSTRA STAGING AREA (Il "Carrello") - final List _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( - listener: (context, state) { - // Quando il BLoC ci dice che ha finito l'upload (Success), chiudiamo la pagina! - if (state.status == CustomerFilesStatus.success && _isUploading) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text("Tutti i file caricati con successo! ✅"), - ), - ); - Navigator.of(context).pop(); - } - if (state.status == CustomerFilesStatus.failure) { - setState(() => _isUploading = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text("Errore: ${state.error}"))); - } - }, - child: Scaffold( - appBar: AppBar( - title: Text("Upload: ${widget.customerName}"), - // Togliamo la freccia indietro se stiamo caricando per evitare disastri - automaticallyImplyLeading: !_isUploading, - ), - body: Stack( - children: [ - Column( - children: [ - // --- SEZIONE PULSANTI (Fotocamera / Galleria) --- - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: _isUploading ? null : _handleCamera, - icon: const Icon(Icons.camera_alt), - label: const Text("SCATTA"), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: _isUploading ? null : _handleFilePicker, - icon: const Icon(Icons.folder), - label: const Text("GALLERIA"), - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), - ), - ], - ), - ), - - const Divider(), - - // --- SEZIONE ANTEPRIME (La GridView Magica) --- - Expanded( - child: _stagedFiles.isEmpty - ? const Center( - child: Text( - "Nessun file selezionato.\nScatta una foto o scegli dalla galleria.", - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), - ), - ) - : GridView.builder( - padding: const EdgeInsets.all(16), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: - 3, // 3 colonne come la galleria dell'iPhone - crossAxisSpacing: 12, - mainAxisSpacing: 12, - ), - itemCount: _stagedFiles.length, - itemBuilder: (context, index) { - final file = _stagedFiles[index]; - final isImg = _isImage(file.name); - - return Stack( - clipBehavior: Clip.none, - children: [ - // L'ANTEPRIMA - Container( - width: double.infinity, - height: double.infinity, - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.grey.shade300, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: isImg - ? Image.file( - File(file.path!), - fit: BoxFit.cover, - ) - : const Column( - mainAxisAlignment: - MainAxisAlignment.center, - children: [ - Icon( - Icons.picture_as_pdf, - color: Colors.red, - size: 36, - ), - SizedBox(height: 4), - Text( - "PDF", - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - - // IL PULSANTE CESTINO (In alto a destra) - Positioned( - top: -8, - right: -8, - child: GestureDetector( - onTap: () { - setState(() { - _stagedFiles.removeAt(index); - }); - }, - child: Container( - padding: const EdgeInsets.all(4), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.close, - color: Colors.white, - size: 16, - ), - ), - ), - ), - ], - ); - }, - ), - ), - - // --- SEZIONE INVIA E CHIUDI --- - SafeArea( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton.icon( - // Il pulsante si accende SOLO se ci sono file nel carrello - onPressed: _stagedFiles.isEmpty || _isUploading - ? null - : _submitAllFiles, - icon: const Icon(Icons.cloud_upload), - label: Text( - "INVIA ${_stagedFiles.length} FILE E CHIUDI", - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.primary, - foregroundColor: Theme.of( - context, - ).colorScheme.onPrimary, - ), - ), - ), - ), - ), - ], - ), - - // --- OVERLAY DI CARICAMENTO (Impedisce tap multipli) --- - if (_isUploading) - Container( - color: Colors.black.withValues(alpha: 0.5), - child: const Center( - child: Card( - child: Padding( - padding: EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - CircularProgressIndicator(), - SizedBox(height: 16), - Text( - "Caricamento in corso...", - style: TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - ), - ), - ), - ), - ], - ), - ), - ); - } - - // --- LOGICA FOTOCAMERA E LIBRERIA --- - Future _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 _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(); - bloc.add(UploadMultipleCustomerFilesEvent(_stagedFiles)); - - // N.B: Il Navigator.pop() viene chiamato dal BlocListener in alto quando lo stato diventa "success"! - } -} diff --git a/lib/features/operations/ui/widgets/details_section.dart b/lib/features/operations/ui/widgets/details_section.dart index 9361d37..c2ab20c 100644 --- a/lib/features/operations/ui/widgets/details_section.dart +++ b/lib/features/operations/ui/widgets/details_section.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; -import 'package:flux/features/master_data/products/ui/quick_product_dialog.dart'; +import 'package:flux/core/widgets/shared_forms/shared_model_section.dart'; import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; @@ -140,129 +139,6 @@ class DetailsSection extends StatelessWidget { ); } - void _showModelModal(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (modalContext) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (_, scrollController) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Seleziona Modello', - style: Theme.of(context).textTheme.titleLarge, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(modalContext), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: TextField( - decoration: InputDecoration( - hintText: 'Cerca modello (es. iPhone 15...)', - prefixIcon: const Icon(Icons.search), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - onChanged: (query) => - context.read().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(); - final existingBrands = context - .read() - .state - .brands; - - final newModel = await showDialog( - context: context, - builder: (dialogContext) { - return BlocProvider.value( - value: context.read(), - 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( - 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() - .updateOperationFields( - modelId: deviceModel.id, - modelDisplayName: deviceModel.nameWithBrand, - ); - Navigator.pop(modalContext); - }, - ); - }, - ); - }, - ), - ), - ], - ); - }, - ); - }, - ); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -334,30 +210,16 @@ class DetailsSection extends StatelessWidget { // 2. SCENARIO FIN (Ricerca Modello/Prodotto) if (currentType == 'Fin') ...[ - ListTile( - title: const Text('Seleziona Dispositivo/Prodotto'), - subtitle: Text( - (currentOp?.modelDisplayName != null && - currentOp!.modelDisplayName!.isNotEmpty) - ? currentOp!.modelDisplayName! - : 'Nessun modello selezionato', - style: TextStyle( - color: - (currentOp?.modelId == null || currentOp!.modelId!.isEmpty) - ? Colors.grey - : null, - fontWeight: - (currentOp?.modelId == null || currentOp!.modelId!.isEmpty) - ? FontWeight.normal - : FontWeight.bold, - ), - ), - trailing: const Icon(Icons.arrow_drop_down), - shape: RoundedRectangleBorder( - side: BorderSide(color: theme.dividerColor), - borderRadius: BorderRadius.circular(8), - ), - onTap: () => _showModelModal(context), + SharedModelSection( + label: 'Seleziona Dispositivo/Prodotto', + modelId: currentOp?.modelId, + modelName: currentOp?.modelDisplayName, + onModelSelected: (id, name) { + context.read().updateOperationFields( + modelId: id, + modelDisplayName: name, + ); + }, ), const SizedBox(height: 16), ], -- 2.43.0 From bdde092976ecf75c58ad121ccd1ca88109d471de Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 12:20:26 +0200 Subject: [PATCH 07/13] refactot ticket model + cubit --- .../tickets/blocs/ticket_form_cubit.dart | 169 ++++++++++++++++++ .../tickets/blocs/ticket_form_state.dart | 40 +++++ lib/features/tickets/models/ticket_model.dart | 41 +++-- 3 files changed, 236 insertions(+), 14 deletions(-) create mode 100644 lib/features/tickets/blocs/ticket_form_cubit.dart create mode 100644 lib/features/tickets/blocs/ticket_form_state.dart diff --git a/lib/features/tickets/blocs/ticket_form_cubit.dart b/lib/features/tickets/blocs/ticket_form_cubit.dart new file mode 100644 index 0000000..24c1cf8 --- /dev/null +++ b/lib/features/tickets/blocs/ticket_form_cubit.dart @@ -0,0 +1,169 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/features/customers/models/customer_model.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +import 'package:flux/features/tickets/data/ticket_repository.dart'; +import 'package:get_it/get_it.dart'; +import 'ticket_form_state.dart'; + +class TicketFormCubit extends Cubit { + final TicketRepository _repository = GetIt.I.get(); + final SessionCubit _sessionCubit = GetIt.I.get(); + + TicketFormCubit() + : super( + // Inizializziamo con un ticket vuoto di default + TicketFormState( + ticket: TicketModel.empty( + companyId: GetIt.I.get().state.company!.id!, + ), + ), + ); + + /// 1. INIZIALIZZAZIONE (Se stiamo modificando un ticket esistente) + void initForm(TicketModel? existingTicket) { + if (existingTicket != null) { + emit( + state.copyWith(ticket: existingTicket, status: TicketFormStatus.ready), + ); + } else { + // È un nuovo ticket! Inseriamo i default base (Azienda, Negozio, Creatore) + final currentUser = _sessionCubit.state.currentStaffMember; + final currentStore = _sessionCubit.state.currentStore; + final companyId = _sessionCubit.state.company?.id ?? ''; + + final newTicket = + TicketModel.empty( + companyId: _sessionCubit.state.company!.id!, + ).copyWith( + companyId: companyId, + storeId: currentStore?.id, + staffId: currentUser?.id, + createdById: currentUser?.name, + // Impostiamo lo stato iniziale + status: TicketStatus.open, + ticketType: TicketType.repair, // Default + ); + + emit(state.copyWith(ticket: newTicket, status: TicketFormStatus.ready)); + } + } + + /// 2. AGGIORNAMENTO CLIENTE (Usato dal nostro SharedCustomerSection!) + void updateCustomer(CustomerModel customer) { + emit( + state.copyWith( + ticket: state.ticket.copyWith( + customerId: customer.id, + customerName: customer.name, + alternativePhoneNumber: customer.phoneNumber, // Comodo come fallback! + ), + ), + ); + } + + /// 3. AGGIORNAMENTO MODELLO (Usato dal nostro SharedModelSection!) + void updateModel({required String modelId, required String modelName}) { + emit( + state.copyWith( + ticket: state.ticket.copyWith( + targetModelId: modelId, + targetModelName: modelName, + ), + ), + ); + } + + void updateCreator({required String staffId, required String staffName}) { + emit( + state.copyWith( + ticket: state.ticket.copyWith(staffId: staffId, createdById: staffName), + ), + ); + } + + /// 4. AGGIORNAMENTO GENERICO DEI CAMPI + void updateFields({ + TicketType? ticketType, + TicketStatus? status, + String? request, + String? targetSn, + String? alternativePhoneNumber, + bool? hasCourtesyDevice, + String? includedAccessories, + String? publicNotes, + String? internalNotes, + double? customerPrice, + double? internalCost, + String? assignedToId, + String? assignedToName, + }) { + emit( + state.copyWith( + ticket: state.ticket.copyWith( + ticketType: ticketType ?? state.ticket.ticketType, + status: status ?? state.ticket.status, + request: request ?? state.ticket.request, + targetSn: targetSn ?? state.ticket.targetSn, + alternativePhoneNumber: + alternativePhoneNumber ?? state.ticket.alternativePhoneNumber, + hasCourtesyDevice: + hasCourtesyDevice ?? state.ticket.hasCourtesyDevice, + includedAccessories: + includedAccessories ?? state.ticket.includedAccessories, + publicNotes: publicNotes ?? state.ticket.publicNotes, + internalNotes: internalNotes ?? state.ticket.internalNotes, + customerPrice: customerPrice ?? state.ticket.customerPrice, + internalCost: internalCost ?? state.ticket.internalCost, + assignedToId: assignedToId ?? state.ticket.assignedToId, + assignedToName: assignedToName ?? state.ticket.assignedToName, + ), + ), + ); + } + + /// 5. SALVATAGGIO + Future saveTicket({required bool keepAdding}) async { + emit(state.copyWith(status: TicketFormStatus.saving)); + + try { + final ticketToSave = state.ticket; + + // Validazione base + if (ticketToSave.customerId == null || ticketToSave.customerId!.isEmpty) { + throw Exception("Seleziona un cliente prima di salvare."); + } + + final savedTicket = await _repository.saveTicket(ticketToSave); + + if (keepAdding) { + emit( + state.copyWith( + status: TicketFormStatus.successAndAddAnother, + // Svuotiamo il form per il prossimo, mantenendo Store e Creatore ATTUALI + ticket: TicketModel.empty().copyWith( + companyId: savedTicket.companyId, + storeId: savedTicket.storeId, + createdById: ticketToSave + .createdById, // Manteniamo quello selezionato nella tendina! + createdByName: ticketToSave.createdByName, + status: TicketStatus.open, + ticketType: TicketType.repair, + ), + ), + ); + } else { + emit( + state.copyWith(status: TicketFormStatus.success, ticket: savedTicket), + ); + } + } catch (e) { + emit( + state.copyWith( + status: TicketFormStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/features/tickets/blocs/ticket_form_state.dart b/lib/features/tickets/blocs/ticket_form_state.dart new file mode 100644 index 0000000..ecf277d --- /dev/null +++ b/lib/features/tickets/blocs/ticket_form_state.dart @@ -0,0 +1,40 @@ +import 'package:equatable/equatable.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +// Adatta gli import al tuo progetto! + +enum TicketFormStatus { + initial, + ready, + loading, + saving, + success, + successAndAddAnother, + failure, +} + +class TicketFormState extends Equatable { + final TicketModel ticket; + final TicketFormStatus status; + final String? errorMessage; + + const TicketFormState({ + required this.ticket, + this.status = TicketFormStatus.initial, + this.errorMessage, + }); + + @override + List get props => [ticket, status, errorMessage]; + + TicketFormState copyWith({ + TicketModel? ticket, + TicketFormStatus? status, + String? errorMessage, + }) { + return TicketFormState( + ticket: ticket ?? this.ticket, + status: status ?? this.status, + errorMessage: errorMessage, + ); + } +} diff --git a/lib/features/tickets/models/ticket_model.dart b/lib/features/tickets/models/ticket_model.dart index 74a7b2b..2ad59e1 100644 --- a/lib/features/tickets/models/ticket_model.dart +++ b/lib/features/tickets/models/ticket_model.dart @@ -96,7 +96,6 @@ class TicketModel extends Equatable { final DateTime? closedAt; final DateTime? returnedAt; final String request; - final String? staffId; final WarrantyType? warrantyType; final String? publicNotes; final String? internalNotes; @@ -112,7 +111,10 @@ class TicketModel extends Equatable { final String? customerName; final String? targetModelName; final String? sourceModelName; - final String? staffName; + final String? createdById; + final String? createdByName; + final String? assignedToId; + final String? assignedToName; final String? includedAccessories; const TicketModel({ @@ -130,7 +132,6 @@ class TicketModel extends Equatable { this.closedAt, this.returnedAt, this.request = '', - this.staffId, this.warrantyType, this.publicNotes, this.internalNotes, @@ -146,14 +147,17 @@ class TicketModel extends Equatable { this.customerName, this.targetModelName, this.sourceModelName, - this.staffName, + this.createdById, + this.createdByName, + this.assignedToId, + this.assignedToName, this.includedAccessories, }); /// Factory per creare un ticket vuoto (utile per i form di creazione) - factory TicketModel.empty({required String companyId, String? storeId}) { + factory TicketModel.empty({String? companyId, String? storeId}) { return TicketModel( - companyId: companyId, + companyId: companyId ?? '', storeId: storeId, ticketType: TicketType.repair, // Valore di default status: TicketStatus.open, @@ -195,7 +199,10 @@ class TicketModel extends Equatable { String? customerName, String? targetModelName, String? sourceModelName, - String? staffName, + String? createdById, + String? createdByName, + String? assignedToId, + String? assignedToName, String? includedAccessories, }) { return TicketModel( @@ -213,7 +220,6 @@ class TicketModel extends Equatable { closedAt: closedAt ?? this.closedAt, returnedAt: returnedAt ?? this.returnedAt, request: request ?? this.request, - staffId: staffId ?? this.staffId, warrantyType: warrantyType ?? this.warrantyType, publicNotes: publicNotes ?? this.publicNotes, internalNotes: internalNotes ?? this.internalNotes, @@ -230,7 +236,10 @@ class TicketModel extends Equatable { customerName: customerName ?? this.customerName, targetModelName: targetModelName ?? this.targetModelName, sourceModelName: sourceModelName ?? this.sourceModelName, - staffName: staffName ?? this.staffName, + createdById: createdById ?? this.createdById, + createdByName: createdByName ?? this.createdByName, + assignedToId: assignedToId ?? this.assignedToId, + assignedToName: assignedToName ?? this.assignedToName, includedAccessories: includedAccessories ?? this.includedAccessories, ); } @@ -259,7 +268,6 @@ class TicketModel extends Equatable { ? DateTime.parse(map['returned_at']).toLocal() : null, request: map['request'] as String? ?? '', - staffId: map['staff_id'] as String?, warrantyType: WarrantyType.fromString(map['warranty_type'] as String?), publicNotes: map['public_notes'] as String?, internalNotes: map['internal_notes'] as String?, @@ -279,7 +287,10 @@ class TicketModel extends Equatable { ?.myFormat(), sourceModelName: (map['source_model']?['name_with_brand'] as String?) ?.myFormat(), - staffName: (map['staff']?['name'] as String?).myFormat(), + createdById: map['staff_id'] as String?, + createdByName: (map['staff']?['name'] as String?).myFormat(), + assignedToId: map['assigned_to_id'] as String?, + assignedToName: (map['assigned_to']?['name'] as String?).myFormat(), includedAccessories: map['included_accessories'] as String?, ); } @@ -301,7 +312,7 @@ class TicketModel extends Equatable { if (returnedAt != null) 'returned_at': returnedAt!.toUtc().toIso8601String(), 'request': request, - 'staff_id': staffId, + 'created_by_id': createdById, 'warranty_type': warrantyType, 'public_notes': publicNotes, 'internal_notes': internalNotes, @@ -334,7 +345,6 @@ class TicketModel extends Equatable { closedAt, returnedAt, request, - staffId, warrantyType, publicNotes, internalNotes, @@ -351,6 +361,9 @@ class TicketModel extends Equatable { customerName, targetModelName, sourceModelName, - staffName, + createdById, + createdByName, + assignedToId, + assignedToName, ]; } -- 2.43.0 From d15d7e458b21bc46b84953611d07171ba7a2ff02 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 12:40:27 +0200 Subject: [PATCH 08/13] a --- ...n.dart => shared_attachments_section.dart} | 27 +- ...tion.dart => shared_customer_section.dart} | 4 +- ...section.dart => shared_staff_section.dart} | 8 +- .../operations/ui/operation_form_screen.dart | 21 +- .../tickets/blocs/ticket_form_cubit.dart | 32 +- lib/features/tickets/models/ticket_model.dart | 1 - .../tickets/ui/ticket_form_screen.dart | 475 ++++++++++++++++++ 7 files changed, 528 insertions(+), 40 deletions(-) rename lib/core/widgets/shared_forms/{operation_files_section.dart => shared_attachments_section.dart} (97%) rename lib/core/widgets/shared_forms/{customer_section.dart => shared_customer_section.dart} (98%) rename lib/core/widgets/shared_forms/{staff_section.dart => shared_staff_section.dart} (97%) create mode 100644 lib/features/tickets/ui/ticket_form_screen.dart diff --git a/lib/core/widgets/shared_forms/operation_files_section.dart b/lib/core/widgets/shared_forms/shared_attachments_section.dart similarity index 97% rename from lib/core/widgets/shared_forms/operation_files_section.dart rename to lib/core/widgets/shared_forms/shared_attachments_section.dart index 56f5620..474b51f 100644 --- a/lib/core/widgets/shared_forms/operation_files_section.dart +++ b/lib/core/widgets/shared_forms/shared_attachments_section.dart @@ -10,7 +10,6 @@ import 'package:flux/features/attachments/ui/quick_rename_dialog.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:get_it/get_it.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:pdfx/pdfx.dart' as px; // Isoliamo pdfx @@ -29,16 +28,24 @@ class _ExportItem { }); } -class OperationFilesSection extends StatefulWidget { - final OperationModel currentOp; +class SharedAttachmentsSection extends StatefulWidget { + final String? parentId; + final String customerDisplayName; + final AttachmentParentType parentType; - const OperationFilesSection({super.key, required this.currentOp}); + const SharedAttachmentsSection({ + super.key, + this.parentId, + this.customerDisplayName = 'Cliente_sconosciuto', + required this.parentType, + }); @override - State createState() => _OperationFilesSectionState(); + State createState() => + _SharedAttachmentsSectionState(); } -class _OperationFilesSectionState extends State { +class _SharedAttachmentsSectionState extends State { String? _exportDirectory; @override @@ -181,7 +188,8 @@ class _OperationFilesSectionState extends State { suggestedName = selectedFiles.first.name; } else { // Se sono più file uniti - suggestedName = '${widget.currentOp.customerDisplayName}_Unito'; + + suggestedName = '${widget.customerDisplayName}_Unito'; } if (!mounted) return; @@ -497,15 +505,14 @@ class _OperationFilesSectionState extends State { }, ), // Bottone Associa a Cliente - if (widget.currentOp.customerId != null && - widget.currentOp.customerId!.isNotEmpty) + if (widget.parentId != null && widget.parentId != '') IconButton( icon: const Icon(Icons.person_add, color: Colors.blue), tooltip: 'Copia nei documenti del Cliente', onPressed: () { context.read().add( LinkAttachmentsToEntityEvent( - targetId: widget.currentOp.customerId!, + targetId: widget.parentId!, targetType: AttachmentParentType.customer, ), ); diff --git a/lib/core/widgets/shared_forms/customer_section.dart b/lib/core/widgets/shared_forms/shared_customer_section.dart similarity index 98% rename from lib/core/widgets/shared_forms/customer_section.dart rename to lib/core/widgets/shared_forms/shared_customer_section.dart index 4c1c9ce..9a2afcc 100644 --- a/lib/core/widgets/shared_forms/customer_section.dart +++ b/lib/core/widgets/shared_forms/shared_customer_section.dart @@ -5,11 +5,11 @@ import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/quick_customer_dialog.dart'; import 'package:flux/features/operations/models/operation_model.dart'; -class CustomerSection extends StatelessWidget { +class SharedCustomerSection extends StatelessWidget { final OperationModel? currentOp; final ValueChanged onCustomerSelected; - const CustomerSection({ + const SharedCustomerSection({ super.key, required this.currentOp, required this.onCustomerSelected, diff --git a/lib/core/widgets/shared_forms/staff_section.dart b/lib/core/widgets/shared_forms/shared_staff_section.dart similarity index 97% rename from lib/core/widgets/shared_forms/staff_section.dart rename to lib/core/widgets/shared_forms/shared_staff_section.dart index fe705b4..ed8b37f 100644 --- a/lib/core/widgets/shared_forms/staff_section.dart +++ b/lib/core/widgets/shared_forms/shared_staff_section.dart @@ -7,13 +7,17 @@ import 'package:flux/features/operations/models/operation_model.dart'; import 'package:get_it/get_it.dart'; class StaffSection extends StatelessWidget { - final OperationModel? currentOp; + final String? label; + final String? staffId; + final String? staffName; final ValueChanged onStaffSelected; const StaffSection({ super.key, - required this.currentOp, required this.onStaffSelected, + this.label, + this.staffId, + this.staffName, }); @override diff --git a/lib/features/operations/ui/operation_form_screen.dart b/lib/features/operations/ui/operation_form_screen.dart index 28e7b35..1271cc8 100644 --- a/lib/features/operations/ui/operation_form_screen.dart +++ b/lib/features/operations/ui/operation_form_screen.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; -import 'package:flux/core/widgets/shared_forms/customer_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_customer_section.dart'; import 'package:flux/features/operations/ui/widgets/details_section.dart'; -import 'package:flux/core/widgets/shared_forms/operation_files_section.dart'; -import 'package:flux/core/widgets/shared_forms/staff_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_attachments_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; import 'package:get_it/get_it.dart'; class OperationFormScreen extends StatefulWidget { @@ -215,8 +216,9 @@ class _OperationFormScreenState extends State { flex: 3, child: SingleChildScrollView( padding: const EdgeInsets.all(16.0), - child: OperationFilesSection( - currentOp: state.currentOperation!, + child: SharedAttachmentsSection( + parentType: AttachmentParentType.operation, + parentId: state.currentOperation?.id, ), ), ), @@ -327,7 +329,7 @@ class _OperationFormScreenState extends State { ), const Divider(height: 50), _buildSectionTitle('Cliente & Riferimento'), - CustomerSection( + SharedCustomerSection( currentOp: currentOp, onCustomerSelected: (customer) { context.read().updateOperationFields( @@ -405,7 +407,12 @@ class _OperationFormScreenState extends State { ), const Divider(height: 32), - if (showFiles) ...[OperationFilesSection(currentOp: currentOp!)], + if (showFiles) ...[ + SharedAttachmentsSection( + parentType: AttachmentParentType.operation, + parentId: currentOp?.id, + ), + ], ], ); } diff --git a/lib/features/tickets/blocs/ticket_form_cubit.dart b/lib/features/tickets/blocs/ticket_form_cubit.dart index 24c1cf8..e4e7782 100644 --- a/lib/features/tickets/blocs/ticket_form_cubit.dart +++ b/lib/features/tickets/blocs/ticket_form_cubit.dart @@ -13,11 +13,7 @@ class TicketFormCubit extends Cubit { TicketFormCubit() : super( // Inizializziamo con un ticket vuoto di default - TicketFormState( - ticket: TicketModel.empty( - companyId: GetIt.I.get().state.company!.id!, - ), - ), + TicketFormState(ticket: TicketModel.empty()), ); /// 1. INIZIALIZZAZIONE (Se stiamo modificando un ticket esistente) @@ -32,18 +28,15 @@ class TicketFormCubit extends Cubit { final currentStore = _sessionCubit.state.currentStore; final companyId = _sessionCubit.state.company?.id ?? ''; - final newTicket = - TicketModel.empty( - companyId: _sessionCubit.state.company!.id!, - ).copyWith( - companyId: companyId, - storeId: currentStore?.id, - staffId: currentUser?.id, - createdById: currentUser?.name, - // Impostiamo lo stato iniziale - status: TicketStatus.open, - ticketType: TicketType.repair, // Default - ); + final newTicket = TicketModel.empty().copyWith( + companyId: companyId, + storeId: currentStore?.id, + createdById: currentUser?.id, + createdByName: currentUser?.name, + // Impostiamo lo stato iniziale + status: TicketStatus.open, + ticketType: TicketType.repair, // Default + ); emit(state.copyWith(ticket: newTicket, status: TicketFormStatus.ready)); } @@ -77,7 +70,10 @@ class TicketFormCubit extends Cubit { void updateCreator({required String staffId, required String staffName}) { emit( state.copyWith( - ticket: state.ticket.copyWith(staffId: staffId, createdById: staffName), + ticket: state.ticket.copyWith( + createdById: staffId, + createdByName: staffName, + ), ), ); } diff --git a/lib/features/tickets/models/ticket_model.dart b/lib/features/tickets/models/ticket_model.dart index 2ad59e1..6fe89ef 100644 --- a/lib/features/tickets/models/ticket_model.dart +++ b/lib/features/tickets/models/ticket_model.dart @@ -183,7 +183,6 @@ class TicketModel extends Equatable { DateTime? closedAt, DateTime? returnedAt, String? request, - String? staffId, WarrantyType? warrantyType, String? publicNotes, String? internalNotes, diff --git a/lib/features/tickets/ui/ticket_form_screen.dart b/lib/features/tickets/ui/ticket_form_screen.dart new file mode 100644 index 0000000..355d3a2 --- /dev/null +++ b/lib/features/tickets/ui/ticket_form_screen.dart @@ -0,0 +1,475 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart'; +import 'package:flux/features/tickets/blocs/ticket_form_state.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +import 'package:flux/core/widgets/shared_forms/shared_customer_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_model_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; // Il tuo widget agnostico dello staff + +class TicketFormScreen extends StatefulWidget { + final TicketModel? existingTicket; + + const TicketFormScreen({super.key, this.existingTicket}); + + @override + State createState() => _TicketFormScreenState(); +} + +class _TicketFormScreenState extends State { + final _formKey = GlobalKey(); + + // Controllers testuali + final _altPhoneCtrl = TextEditingController(); + final _serialCtrl = TextEditingController(); + final _requestCtrl = TextEditingController(); + final _accessoriesCtrl = TextEditingController(); + final _publicNotesCtrl = TextEditingController(); + final _internalNotesCtrl = TextEditingController(); + final _priceCtrl = TextEditingController(); + final _costCtrl = TextEditingController(); + + bool _isInitialized = false; + + @override + void initState() { + super.initState(); + // Inizializziamo il Cubit + context.read().initForm(widget.existingTicket); + } + + @override + void dispose() { + _altPhoneCtrl.dispose(); + _serialCtrl.dispose(); + _requestCtrl.dispose(); + _accessoriesCtrl.dispose(); + _publicNotesCtrl.dispose(); + _internalNotesCtrl.dispose(); + _priceCtrl.dispose(); + _costCtrl.dispose(); + super.dispose(); + } + + // Sincronizza i controller con lo stato iniziale senza sovrascrivere se l'utente sta digitando + void _syncTextControllers(TicketModel model) { + if (_altPhoneCtrl.text.isEmpty) + _altPhoneCtrl.text = model.alternativePhoneNumber ?? ''; + if (_serialCtrl.text.isEmpty) _serialCtrl.text = model.targetSn ?? ''; + if (_requestCtrl.text.isEmpty) _requestCtrl.text = model.request ?? ''; + if (_accessoriesCtrl.text.isEmpty) + _accessoriesCtrl.text = model.includedAccessories ?? ''; + if (_publicNotesCtrl.text.isEmpty) + _publicNotesCtrl.text = model.publicNotes ?? ''; + if (_internalNotesCtrl.text.isEmpty) + _internalNotesCtrl.text = model.internalNotes ?? ''; + if (_priceCtrl.text.isEmpty && model.customerPrice > 0) + _priceCtrl.text = model.customerPrice.toString(); + if (_costCtrl.text.isEmpty && model.internalCost > 0) + _costCtrl.text = model.internalCost.toString(); + _isInitialized = true; + } + + // Chiamato prima del salvataggio per pushare i testi nei campi del Cubit + void _flushControllersToCubit() { + context.read().updateFields( + alternativePhoneNumber: _altPhoneCtrl.text, + targetSn: _serialCtrl.text, + request: _requestCtrl.text, + includedAccessories: _accessoriesCtrl.text, + publicNotes: _publicNotesCtrl.text, + internalNotes: _internalNotesCtrl.text, + customerPrice: double.tryParse(_priceCtrl.text) ?? 0.0, + internalCost: double.tryParse(_costCtrl.text) ?? 0.0, + ); + } + + void _saveTicket({required bool keepAdding}) { + if (_formKey.currentState!.validate()) { + _flushControllersToCubit(); + context.read().saveTicket(keepAdding: keepAdding); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (state.status == TicketFormStatus.ready && !_isInitialized) { + _syncTextControllers(state.ticket); + } + + if (state.status == TicketFormStatus.success) { + Navigator.of(context).pop(); + } else if (state.status == TicketFormStatus.successAndAddAnother) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Scheda salvata! Inserisci la prossima.'), + ), + ); + // Svuotiamo i controller per il nuovo ticket + _altPhoneCtrl.clear(); + _serialCtrl.clear(); + _requestCtrl.clear(); + _accessoriesCtrl.clear(); + _publicNotesCtrl.clear(); + _internalNotesCtrl.clear(); + _priceCtrl.clear(); + _costCtrl.clear(); + _isInitialized = false; + } else if (state.status == TicketFormStatus.failure) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Errore di salvataggio'), + backgroundColor: theme.colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + final ticket = state.ticket; + + return Scaffold( + appBar: AppBar( + title: Text( + ticket.id == null ? 'Nuova Scheda Assistenza' : 'Modifica Scheda', + ), + actions: [ + // PICCOLO BADGE DI STATO IN ALTO A DESTRA + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Chip( + label: Text( + ticket.status.name.toUpperCase(), + style: const TextStyle(color: Colors.white, fontSize: 10), + ), + backgroundColor: ticket.status.color, + ), + ), + ], + ), + body: Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Center( + child: ConstrainedBox( + // Limitiamo la larghezza su schermi grandi per non renderlo illeggibile + constraints: const BoxConstraints(maxWidth: 800), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // --- CARD 1: INTESTAZIONE & CLIENTE --- + _buildCard( + title: 'Anagrafica', + icon: Icons.person, + children: [ + // Qui usiamo la StaffSection per scegliere "A nome di chi" apriamo la scheda + StaffSection( + label: 'Creato Da', + staffId: ticket.createdById, + staffName: ticket.createdByName, + onStaffSelected: (staff) => + context.read().updateCreator( + staffId: staff.id, + staffName: staff.name, + ), + ), + const Divider(height: 32), + SharedCustomerSection( + customerId: ticket.customerId, + customerName: ticket.customerName, + onCustomerSelected: (customer) => context + .read() + .updateCustomer(customer), + ), + const SizedBox(height: 16), + TextFormField( + controller: _altPhoneCtrl, + decoration: const InputDecoration( + labelText: + 'Recapito Alternativo (es. se lascia il telefono principale)', + prefixIcon: Icon(Icons.phone), + ), + ), + ], + ), + + // --- CARD 2: DISPOSITIVO --- + _buildCard( + title: 'Dispositivo', + icon: Icons.devices, + children: [ + SharedModelSection( + label: 'Modello da Riparare', + modelId: ticket.targetModelId, + modelName: ticket.targetModelName, + onModelSelected: (id, name) => context + .read() + .updateModel(modelId: id, modelName: name), + ), + const SizedBox(height: 16), + TextFormField( + controller: _serialCtrl, + decoration: const InputDecoration( + labelText: 'Seriale / IMEI', + prefixIcon: Icon(Icons.qr_code), + ), + ), + ], + ), + + // --- CARD 3: PROBLEMA E LAVORAZIONE --- + _buildCard( + title: 'Dettagli Riparazione', + icon: Icons.build, + children: [ + // Tipo Lavorazione e Tipo Garanzia + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: ticket.ticketType, + decoration: const InputDecoration( + labelText: 'Tipo Lavorazione', + ), + items: TicketType.values + .map( + (t) => DropdownMenuItem( + value: t, + child: Text( + t.name, + ), // Se hai estensioni, usa t.displayName + ), + ) + .toList(), + onChanged: (val) => context + .read() + .updateFields(ticketType: val), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + value: ticket.status, + decoration: const InputDecoration( + labelText: 'Stato Attuale', + ), + items: TicketStatus.values + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(s.name), // Idem qui + ), + ) + .toList(), + onChanged: (val) => context + .read() + .updateFields(status: val), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _requestCtrl, + maxLines: 4, + decoration: const InputDecoration( + labelText: + 'Difetto dichiarato o Richiesta del cliente', + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _accessoriesCtrl, + decoration: const InputDecoration( + labelText: + 'Accessori Consegnati (es. cover, caricatore...)', + prefixIcon: Icon(Icons.cable), + ), + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Prestato Telefono di Cortesia?'), + value: ticket.hasCourtesyDevice, + onChanged: (val) => context + .read() + .updateFields(hasCourtesyDevice: val), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: theme.dividerColor), + ), + ), + ], + ), + + // --- CARD 4: COSTI E NOTE --- + _buildCard( + title: 'Costi & Note', + icon: Icons.euro, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _priceCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Preventivo Cliente (€)', + prefixIcon: Icon(Icons.sell_outlined), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _costCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Nostro Costo (€)', + prefixIcon: Icon( + Icons.shopping_cart_outlined, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _publicNotesCtrl, + maxLines: 2, + decoration: const InputDecoration( + labelText: + 'Note Pubbliche (Visibili su ricevuta)', + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _internalNotesCtrl, + maxLines: 3, + decoration: InputDecoration( + labelText: 'Note Interne (Solo per lo Staff)', + fillColor: Colors.amber.withValues(alpha: 0.1), + filled: true, + ), + ), + ], + ), + + // --- CARD 5: ASSEGNAZIONE & FILE --- + _buildCard( + title: 'Assegnazione Tecnico', + icon: Icons.engineering, + children: [ + StaffSection( + label: 'Assegnato A', + staffId: ticket.assignedToId, + staffName: ticket.assignedToName, + onStaffSelected: (staff) => + context.read().updateFields( + assignedToId: staff.id, + assignedToName: staff.name, + ), + ), + // TODO: Inserire qui il tuo SharedAttachmentsSection per foto pre-riparazione + ], + ), + + const SizedBox(height: 80), // Spazio per il bottom nav + ], + ), + ), + ), + ), + ), + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Expanded( + flex: 1, + child: OutlinedButton( + onPressed: state.status == TicketFormStatus.saving + ? null + : () => _saveTicket(keepAdding: true), + child: const Text( + 'Salva e Aggiungi Altro', + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 1, + child: ElevatedButton( + onPressed: state.status == TicketFormStatus.saving + ? null + : () => _saveTicket(keepAdding: false), + child: state.status == TicketFormStatus.saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text('Salva ed Esci'), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + // Helper per creare le Card esteticamente coerenti + Widget _buildCard({ + required String title, + required IconData icon, + required List children, + }) { + return Card( + margin: const EdgeInsets.only(bottom: 24), + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Text( + title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ], + ), + const Divider(height: 32), + ...children, + ], + ), + ), + ); + } +} -- 2.43.0 From 040db4ad7966011f1d39f7da85d5cce4cacac593 Mon Sep 17 00:00:00 2001 From: mark-cachy Date: Wed, 6 May 2026 19:25:17 +0200 Subject: [PATCH 09/13] jkg --- .../shared_forms/shared_customer_section.dart | 14 ++++---- .../shared_forms/shared_staff_section.dart | 4 +-- .../operations/ui/operation_form_screen.dart | 6 ++-- .../tickets/blocs/ticket_form_cubit.dart | 6 ++-- .../tickets/data/ticket_repository.dart | 13 +++++--- lib/features/tickets/models/ticket_model.dart | 33 +++++++++---------- .../tickets/ui/ticket_form_screen.dart | 31 ++++++++++------- .../tickets/ui/ticket_list_screen.dart | 6 ++-- 8 files changed, 60 insertions(+), 53 deletions(-) diff --git a/lib/core/widgets/shared_forms/shared_customer_section.dart b/lib/core/widgets/shared_forms/shared_customer_section.dart index 9a2afcc..d2d75ad 100644 --- a/lib/core/widgets/shared_forms/shared_customer_section.dart +++ b/lib/core/widgets/shared_forms/shared_customer_section.dart @@ -3,22 +3,22 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/features/customers/blocs/customers_cubit.dart'; import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/quick_customer_dialog.dart'; -import 'package:flux/features/operations/models/operation_model.dart'; class SharedCustomerSection extends StatelessWidget { - final OperationModel? currentOp; + final String? customerId; + final String? customerName; final ValueChanged onCustomerSelected; const SharedCustomerSection({ super.key, - required this.currentOp, + this.customerId, + this.customerName, required this.onCustomerSelected, }); @override Widget build(BuildContext context) { - final hasCustomer = - currentOp?.customerId != null && currentOp!.customerId!.isNotEmpty; + final hasCustomer = customerId != null && customerId!.isNotEmpty; final theme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -47,9 +47,7 @@ class SharedCustomerSection extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - hasCustomer - ? currentOp!.customerDisplayName! - : 'Seleziona Cliente *', + hasCustomer ? customerName! : 'Seleziona Cliente *', style: TextStyle( fontWeight: hasCustomer ? FontWeight.bold diff --git a/lib/core/widgets/shared_forms/shared_staff_section.dart b/lib/core/widgets/shared_forms/shared_staff_section.dart index ed8b37f..0aa8b86 100644 --- a/lib/core/widgets/shared_forms/shared_staff_section.dart +++ b/lib/core/widgets/shared_forms/shared_staff_section.dart @@ -3,7 +3,6 @@ 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/master_data/staff/models/staff_member_model.dart'; -import 'package:flux/features/operations/models/operation_model.dart'; import 'package:get_it/get_it.dart'; class StaffSection extends StatelessWidget { @@ -24,8 +23,7 @@ class StaffSection extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final selectedStaffId = - currentOp?.staffId ?? - GetIt.I.get().state.currentStaffMember?.id; + staffId ?? GetIt.I.get().state.currentStaffMember?.id; return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/operations/ui/operation_form_screen.dart b/lib/features/operations/ui/operation_form_screen.dart index 1271cc8..cf58ad5 100644 --- a/lib/features/operations/ui/operation_form_screen.dart +++ b/lib/features/operations/ui/operation_form_screen.dart @@ -319,7 +319,8 @@ class _OperationFormScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ StaffSection( - currentOp: currentOp, + staffId: currentOp?.staffId, + staffName: currentOp?.staffDisplayName, onStaffSelected: (staff) => { context.read().updateOperationFields( staffId: staff.id, @@ -330,7 +331,8 @@ class _OperationFormScreenState extends State { const Divider(height: 50), _buildSectionTitle('Cliente & Riferimento'), SharedCustomerSection( - currentOp: currentOp, + customerId: currentOp?.customerId, + customerName: currentOp?.customerDisplayName, onCustomerSelected: (customer) { context.read().updateOperationFields( customerId: customer.id, diff --git a/lib/features/tickets/blocs/ticket_form_cubit.dart b/lib/features/tickets/blocs/ticket_form_cubit.dart index e4e7782..abe4479 100644 --- a/lib/features/tickets/blocs/ticket_form_cubit.dart +++ b/lib/features/tickets/blocs/ticket_form_cubit.dart @@ -34,7 +34,7 @@ class TicketFormCubit extends Cubit { createdById: currentUser?.id, createdByName: currentUser?.name, // Impostiamo lo stato iniziale - status: TicketStatus.open, + ticketStatus: TicketStatus.open, ticketType: TicketType.repair, // Default ); @@ -98,7 +98,7 @@ class TicketFormCubit extends Cubit { state.copyWith( ticket: state.ticket.copyWith( ticketType: ticketType ?? state.ticket.ticketType, - status: status ?? state.ticket.status, + ticketStatus: status ?? state.ticket.ticketStatus, request: request ?? state.ticket.request, targetSn: targetSn ?? state.ticket.targetSn, alternativePhoneNumber: @@ -143,7 +143,7 @@ class TicketFormCubit extends Cubit { createdById: ticketToSave .createdById, // Manteniamo quello selezionato nella tendina! createdByName: ticketToSave.createdByName, - status: TicketStatus.open, + ticketStatus: TicketStatus.open, ticketType: TicketType.repair, ), ), diff --git a/lib/features/tickets/data/ticket_repository.dart b/lib/features/tickets/data/ticket_repository.dart index df30905..d274e22 100644 --- a/lib/features/tickets/data/ticket_repository.dart +++ b/lib/features/tickets/data/ticket_repository.dart @@ -27,7 +27,8 @@ class TicketRepository { .select(''' *, customer (*), - staff_member (*), + created_by:staff_member!ticket_staff_id_fkey (*), + assigned_to:staff_member!ticket_assigned_to_id_fkey (*), target_model:model!ticket_model_id_1_fkey (*), source_model:model!ticket_model_id_2_fkey (*) ''') @@ -83,7 +84,8 @@ class TicketRepository { .select(''' *, customer (*), - staff_member (*), + created_by:staff_member!ticket_staff_id_fkey (*), + assigned_to:staff_member!ticket_assigned_to_id_fkey (*), target_model:model!ticket_model_id_1_fkey (*), source_model:model!ticket_model_id_2_fkey (*) ''') @@ -144,8 +146,8 @@ class TicketRepository { // 2. Filtriamo in memoria! final urgentTickets = allStoreTickets.where((ticket) { // Escludiamo quelli già chiusi o consegnati - if (ticket.status == TicketStatus.closed || - ticket.status == TicketStatus.ready) { + if (ticket.ticketStatus == TicketStatus.closed || + ticket.ticketStatus == TicketStatus.ready) { return false; } @@ -178,7 +180,8 @@ class TicketRepository { customer (*), target_model:model!ticket_model_id_1_fkey (*), source_model:model!ticket_model_id_2_fkey (*), - staff:staff_member!ticket_staff_id_fkey (*) + created_by:staff_member!ticket_staff_id_fkey (*), + assigned_to:staff_member!ticket_assigned_to_id_fkey (*), ''') .eq('id', ticketId) .single(); diff --git a/lib/features/tickets/models/ticket_model.dart b/lib/features/tickets/models/ticket_model.dart index 6fe89ef..6880772 100644 --- a/lib/features/tickets/models/ticket_model.dart +++ b/lib/features/tickets/models/ticket_model.dart @@ -35,8 +35,7 @@ enum TicketStatus { final String displayValue; const TicketStatus(this.value, this.displayValue); - static TicketStatus? fromString(String? val) { - if (val == null) return null; + static TicketStatus fromString(String? val) { return TicketStatus.values.firstWhere( (e) => e.value == val, orElse: () => TicketStatus.open, @@ -103,9 +102,9 @@ class TicketModel extends Equatable { final String? alternativePhoneNumber; final bool hasCourtesyDevice; final TicketType ticketType; - final TicketStatus? status; + final TicketStatus ticketStatus; final DateTime? estimatedDeliveryAt; - final TicketResult? result; + final TicketResult? ticketResult; final String? resolutionNotes; final String? legacyId; final String? customerName; @@ -139,9 +138,9 @@ class TicketModel extends Equatable { this.alternativePhoneNumber, this.hasCourtesyDevice = false, required this.ticketType, - this.status, + this.ticketStatus = TicketStatus.closed, this.estimatedDeliveryAt, - this.result, + this.ticketResult, this.resolutionNotes, this.legacyId, this.customerName, @@ -160,7 +159,7 @@ class TicketModel extends Equatable { companyId: companyId ?? '', storeId: storeId, ticketType: TicketType.repair, // Valore di default - status: TicketStatus.open, + ticketStatus: TicketStatus.open, customerPrice: 0.0, internalCost: 0.0, hasCourtesyDevice: false, @@ -190,9 +189,9 @@ class TicketModel extends Equatable { String? alternativePhoneNumber, bool? hasCourtesyDevice, TicketType? ticketType, - TicketStatus? status, + TicketStatus? ticketStatus, DateTime? estimatedDeliveryAt, - TicketResult? result, + TicketResult? ticketResult, String? resolutionNotes, String? legacyId, String? customerName, @@ -227,9 +226,9 @@ class TicketModel extends Equatable { alternativePhoneNumber ?? this.alternativePhoneNumber, hasCourtesyDevice: hasCourtesyDevice ?? this.hasCourtesyDevice, ticketType: ticketType ?? this.ticketType, - status: status ?? this.status, + ticketStatus: ticketStatus ?? this.ticketStatus, estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt, - result: result ?? this.result, + ticketResult: ticketResult ?? this.ticketResult, resolutionNotes: resolutionNotes ?? this.resolutionNotes, legacyId: legacyId ?? this.legacyId, customerName: customerName ?? this.customerName, @@ -274,11 +273,11 @@ class TicketModel extends Equatable { alternativePhoneNumber: map['alternative_phone_number'] as String?, hasCourtesyDevice: map['has_courtesy_device'] as bool? ?? false, ticketType: TicketType.fromString(map['ticket_type'] as String), - status: TicketStatus.fromString(map['status'] as String?), + ticketStatus: TicketStatus.fromString(map['ticket_status'] as String), estimatedDeliveryAt: map['estimated_delivery_at'] != null ? DateTime.parse(map['estimated_delivery_at']).toLocal() : null, - result: TicketResult.fromString(map['result'] as String?), + ticketResult: TicketResult.fromString(map['ticket_result'] as String?), resolutionNotes: map['resolution_notes'] as String?, legacyId: map['legacy_id'] as String?, customerName: (map['customer']?['name'] as String?).myFormat(), @@ -318,10 +317,10 @@ class TicketModel extends Equatable { 'alternative_phone_number': alternativePhoneNumber, 'has_courtesy_device': hasCourtesyDevice, 'ticket_type': ticketType.value, - if (status != null) 'status': status!.value, + 'ticket_status': ticketStatus.value, if (estimatedDeliveryAt != null) 'estimated_delivery_at': estimatedDeliveryAt!.toUtc().toIso8601String(), - if (result != null) 'result': result!.value, + if (ticketResult != null) 'ticket_result': ticketResult!.value, 'resolution_notes': resolutionNotes, 'legacy_id': legacyId, 'included_accessories': includedAccessories, @@ -351,9 +350,9 @@ class TicketModel extends Equatable { alternativePhoneNumber, hasCourtesyDevice, ticketType, - status, + ticketStatus, estimatedDeliveryAt, - result, + ticketResult, resolutionNotes, legacyId, includedAccessories, diff --git a/lib/features/tickets/ui/ticket_form_screen.dart b/lib/features/tickets/ui/ticket_form_screen.dart index 355d3a2..42f2321 100644 --- a/lib/features/tickets/ui/ticket_form_screen.dart +++ b/lib/features/tickets/ui/ticket_form_screen.dart @@ -5,7 +5,8 @@ import 'package:flux/features/tickets/blocs/ticket_form_state.dart'; import 'package:flux/features/tickets/models/ticket_model.dart'; import 'package:flux/core/widgets/shared_forms/shared_customer_section.dart'; import 'package:flux/core/widgets/shared_forms/shared_model_section.dart'; -import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; // Il tuo widget agnostico dello staff +import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; +import 'package:flux/features/tickets/models/ticket_status_extension.dart'; // Il tuo widget agnostico dello staff class TicketFormScreen extends StatefulWidget { final TicketModel? existingTicket; @@ -53,20 +54,26 @@ class _TicketFormScreenState extends State { // Sincronizza i controller con lo stato iniziale senza sovrascrivere se l'utente sta digitando void _syncTextControllers(TicketModel model) { - if (_altPhoneCtrl.text.isEmpty) + if (_altPhoneCtrl.text.isEmpty) { _altPhoneCtrl.text = model.alternativePhoneNumber ?? ''; + } if (_serialCtrl.text.isEmpty) _serialCtrl.text = model.targetSn ?? ''; - if (_requestCtrl.text.isEmpty) _requestCtrl.text = model.request ?? ''; - if (_accessoriesCtrl.text.isEmpty) + if (_requestCtrl.text.isEmpty) _requestCtrl.text = model.request; + if (_accessoriesCtrl.text.isEmpty) { _accessoriesCtrl.text = model.includedAccessories ?? ''; - if (_publicNotesCtrl.text.isEmpty) + } + if (_publicNotesCtrl.text.isEmpty) { _publicNotesCtrl.text = model.publicNotes ?? ''; - if (_internalNotesCtrl.text.isEmpty) + } + if (_internalNotesCtrl.text.isEmpty) { _internalNotesCtrl.text = model.internalNotes ?? ''; - if (_priceCtrl.text.isEmpty && model.customerPrice > 0) + } + if (_priceCtrl.text.isEmpty && model.customerPrice > 0) { _priceCtrl.text = model.customerPrice.toString(); - if (_costCtrl.text.isEmpty && model.internalCost > 0) + } + if (_costCtrl.text.isEmpty && model.internalCost > 0) { _costCtrl.text = model.internalCost.toString(); + } _isInitialized = true; } @@ -143,10 +150,10 @@ class _TicketFormScreenState extends State { padding: const EdgeInsets.only(right: 16.0), child: Chip( label: Text( - ticket.status.name.toUpperCase(), + ticket.ticketStatus!.name.toUpperCase(), style: const TextStyle(color: Colors.white, fontSize: 10), ), - backgroundColor: ticket.status.color, + backgroundColor: ticket.ticketStatus.color, ), ), ], @@ -174,7 +181,7 @@ class _TicketFormScreenState extends State { staffName: ticket.createdByName, onStaffSelected: (staff) => context.read().updateCreator( - staffId: staff.id, + staffId: staff.id!, staffName: staff.name, ), ), @@ -254,7 +261,7 @@ class _TicketFormScreenState extends State { const SizedBox(width: 16), Expanded( child: DropdownButtonFormField( - value: ticket.status, + value: ticket.ticketStatus, decoration: const InputDecoration( labelText: 'Stato Attuale', ), diff --git a/lib/features/tickets/ui/ticket_list_screen.dart b/lib/features/tickets/ui/ticket_list_screen.dart index 43a0503..30751ce 100644 --- a/lib/features/tickets/ui/ticket_list_screen.dart +++ b/lib/features/tickets/ui/ticket_list_screen.dart @@ -192,8 +192,8 @@ class _TicketCard extends StatelessWidget { @override Widget build(BuildContext context) { - final statusColor = ticket.status?.color ?? Colors.grey; - final statusIcon = ticket.status?.icon ?? Icons.help_outline; + final statusColor = ticket.ticketStatus.color; + final statusIcon = ticket.ticketStatus.icon; return Card( margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), @@ -244,7 +244,7 @@ class _TicketCard extends StatelessWidget { Icon(statusIcon, size: 14, color: statusColor), const SizedBox(width: 4), Text( - ticket.status?.displayValue ?? 'N/D', + ticket.ticketStatus.displayValue, style: TextStyle( fontSize: 12, color: statusColor, -- 2.43.0 From c6321d6580c511334f08bfbc19f9ca17a44e6eb8 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Wed, 6 May 2026 20:40:02 +0200 Subject: [PATCH 10/13] =?UTF-8?q?ticket=20form=20funzionante!=20devo=20anc?= =?UTF-8?q?ora=20provare=20a=20salvare=20per=C3=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/core/routes/app_router.dart | 31 ++++++++++++++++++- ..._section.dart => attachments_section.dart} | 0 ...mer_section.dart => customer_section.dart} | 0 ..._screen.dart => mobile_upload_screen.dart} | 0 ..._model_section.dart => model_section.dart} | 0 ..._staff_section.dart => staff_section.dart} | 31 ++++++++++++++----- lib/features/home/ui/home_screen.dart | 2 +- .../operations/ui/operation_form_screen.dart | 6 ++-- .../ui/widgets/details_section.dart | 2 +- .../tickets/blocs/ticket_form_cubit.dart | 26 +++++++++++++++- .../tickets/data/ticket_repository.dart | 2 +- .../tickets/ui/ticket_form_screen.dart | 20 +++++++----- 12 files changed, 97 insertions(+), 23 deletions(-) rename lib/core/widgets/shared_forms/{shared_attachments_section.dart => attachments_section.dart} (100%) rename lib/core/widgets/shared_forms/{shared_customer_section.dart => customer_section.dart} (100%) rename lib/core/widgets/shared_forms/{shared_mobile_upload_screen.dart => mobile_upload_screen.dart} (100%) rename lib/core/widgets/shared_forms/{shared_model_section.dart => model_section.dart} (100%) rename lib/core/widgets/shared_forms/{shared_staff_section.dart => staff_section.dart} (85%) diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index ff8160b..9673721 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -6,7 +6,7 @@ import 'package:flux/core/data/core_repository.dart'; import 'package:flux/core/layout/app_shell.dart'; import 'package:flux/core/utils/extensions.dart'; import 'package:flux/core/widgets/set_password_screen.dart'; -import 'package:flux/core/widgets/shared_forms/shared_mobile_upload_screen.dart'; +import 'package:flux/core/widgets/shared_forms/mobile_upload_screen.dart'; import 'package:flux/features/auth/ui/auth_screen.dart'; import 'package:flux/features/customers/blocs/customers_cubit.dart'; import 'package:flux/features/customers/models/customer_model.dart'; @@ -27,7 +27,10 @@ import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:flux/features/operations/ui/operation_form_screen.dart'; import 'package:flux/features/operations/ui/operations_screen.dart'; +import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart'; import 'package:flux/features/tickets/blocs/ticket_list_cubit.dart'; +import 'package:flux/features/tickets/models/ticket_model.dart'; +import 'package:flux/features/tickets/ui/ticket_form_screen.dart'; import 'package:flux/features/tickets/ui/ticket_list_screen.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -157,6 +160,32 @@ class AppRouter { ), // --- DETTAGLI E OPERATIVITÀ (FUORI DALLA SHELL - TUTTO SCHERMO) --- + GoRoute( + // Il path sarà es. /tickets/form/123 oppure /tickets/form/new + path: '/tickets/form/:id', + builder: (context, state) { + // 1. Leggiamo l'ID dall'URL + final String pathId = state.pathParameters['id'] ?? 'new'; + + // 2. Leggiamo l'oggetto dalla RAM (se arriviamo da un tap interno all'app) + final TicketModel? ticketFromExtra = state.extra as TicketModel?; + + // 3. Capiamo se è un nuovo ticket o una modifica + final String? realTicketId = pathId == 'new' ? null : pathId; + context.read().loadStaffForStore( + GetIt.I.get().state.currentStore!.id!, + ); + context.read().loadCustomers(); + + return BlocProvider( + create: (context) => TicketFormCubit(), + child: TicketFormScreen( + ticketId: realTicketId, + existingTicket: ticketFromExtra, + ), + ); + }, + ), GoRoute( path: '/customer/:id', builder: (context, state) { diff --git a/lib/core/widgets/shared_forms/shared_attachments_section.dart b/lib/core/widgets/shared_forms/attachments_section.dart similarity index 100% rename from lib/core/widgets/shared_forms/shared_attachments_section.dart rename to lib/core/widgets/shared_forms/attachments_section.dart diff --git a/lib/core/widgets/shared_forms/shared_customer_section.dart b/lib/core/widgets/shared_forms/customer_section.dart similarity index 100% rename from lib/core/widgets/shared_forms/shared_customer_section.dart rename to lib/core/widgets/shared_forms/customer_section.dart diff --git a/lib/core/widgets/shared_forms/shared_mobile_upload_screen.dart b/lib/core/widgets/shared_forms/mobile_upload_screen.dart similarity index 100% rename from lib/core/widgets/shared_forms/shared_mobile_upload_screen.dart rename to lib/core/widgets/shared_forms/mobile_upload_screen.dart diff --git a/lib/core/widgets/shared_forms/shared_model_section.dart b/lib/core/widgets/shared_forms/model_section.dart similarity index 100% rename from lib/core/widgets/shared_forms/shared_model_section.dart rename to lib/core/widgets/shared_forms/model_section.dart diff --git a/lib/core/widgets/shared_forms/shared_staff_section.dart b/lib/core/widgets/shared_forms/staff_section.dart similarity index 85% rename from lib/core/widgets/shared_forms/shared_staff_section.dart rename to lib/core/widgets/shared_forms/staff_section.dart index 0aa8b86..ac52971 100644 --- a/lib/core/widgets/shared_forms/shared_staff_section.dart +++ b/lib/core/widgets/shared_forms/staff_section.dart @@ -22,6 +22,7 @@ class StaffSection extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + // Se staffId è nullo, proviamo a preselezionare l'utente loggato final selectedStaffId = staffId ?? GetIt.I.get().state.currentStaffMember?.id; @@ -31,7 +32,8 @@ class StaffSection extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 12.0), child: Text( - 'Operatore', + label ?? + 'Operatore', // <-- FIX: Ora usa l'etichetta passata dal form! style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.bold, ), @@ -39,8 +41,28 @@ class StaffSection extends StatelessWidget { ), BlocBuilder( builder: (context, state) { - // Dati finti per farti vedere la UI, piallali quando attacchi il BlocBuilder! + // FIX: Aggiunto un controllo se sta caricando + if (state.status == StaffStatus.loading) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } + final staffMembers = state.storeStaff; + + // FIX: Feedback visivo se la lista è vuota + if (staffMembers.isEmpty) { + return const Text( + 'Nessun operatore caricato. Controlla il Cubit!', + style: TextStyle(color: Colors.red), + ); + } + final currentLoggedStaffMember = GetIt.I .get() .state @@ -55,11 +77,6 @@ class StaffSection extends StatelessWidget { return GestureDetector( onTap: () { onStaffSelected(staff); - - /* context.read().updateOperationFields( - staffId: staff.id, - staffDisplayName: staff.name, - ); */ }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 9029872..98a4106 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -196,7 +196,7 @@ class HomeScreen extends StatelessWidget { color: Colors.redAccent, onTap: () { // Andiamo alla lista! (Da lì poi aggiungeremo il tasto "+" per il form) - context.push('/tickets'); + context.push('/tickets/form/new'); }, ), const SizedBox(width: 12), diff --git a/lib/features/operations/ui/operation_form_screen.dart b/lib/features/operations/ui/operation_form_screen.dart index cf58ad5..a92a719 100644 --- a/lib/features/operations/ui/operation_form_screen.dart +++ b/lib/features/operations/ui/operation_form_screen.dart @@ -4,10 +4,10 @@ import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; -import 'package:flux/core/widgets/shared_forms/shared_customer_section.dart'; +import 'package:flux/core/widgets/shared_forms/customer_section.dart'; import 'package:flux/features/operations/ui/widgets/details_section.dart'; -import 'package:flux/core/widgets/shared_forms/shared_attachments_section.dart'; -import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; +import 'package:flux/core/widgets/shared_forms/attachments_section.dart'; +import 'package:flux/core/widgets/shared_forms/staff_section.dart'; import 'package:get_it/get_it.dart'; class OperationFormScreen extends StatefulWidget { diff --git a/lib/features/operations/ui/widgets/details_section.dart b/lib/features/operations/ui/widgets/details_section.dart index c2ab20c..73deeff 100644 --- a/lib/features/operations/ui/widgets/details_section.dart +++ b/lib/features/operations/ui/widgets/details_section.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flux/core/widgets/shared_forms/shared_model_section.dart'; +import 'package:flux/core/widgets/shared_forms/model_section.dart'; import 'package:flux/features/master_data/providers/blocs/provider_cubit.dart'; import 'package:flux/features/operations/blocs/operations_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; diff --git a/lib/features/tickets/blocs/ticket_form_cubit.dart b/lib/features/tickets/blocs/ticket_form_cubit.dart index abe4479..8ee7280 100644 --- a/lib/features/tickets/blocs/ticket_form_cubit.dart +++ b/lib/features/tickets/blocs/ticket_form_cubit.dart @@ -17,12 +17,36 @@ class TicketFormCubit extends Cubit { ); /// 1. INIZIALIZZAZIONE (Se stiamo modificando un ticket esistente) - void initForm(TicketModel? existingTicket) { + Future initForm({String? id, TicketModel? existingTicket}) async { if (existingTicket != null) { + // SCENARIO 1 (App Native / Navigazione interna Web): + // Abbiamo l'oggetto intero passato via 'extra'. Lo mostriamo all'istante! emit( state.copyWith(ticket: existingTicket, status: TicketFormStatus.ready), ); + } else if (id != null) { + // SCENARIO 2 (Web Refresh o Link condiviso): + // L'utente ha premuto F5 su /tickets/form/123. L'extra è andato perso, ma abbiamo l'ID! + emit( + state.copyWith(status: TicketFormStatus.loading), + ); // Mostriamo uno spinner + try { + final fetchedTicket = await _repository.getTicketById( + id, + ); // Lo scarichiamo! + emit( + state.copyWith(ticket: fetchedTicket, status: TicketFormStatus.ready), + ); + } catch (e) { + emit( + state.copyWith( + status: TicketFormStatus.failure, + errorMessage: 'Ticket non trovato', + ), + ); + } } else { + // SCENARIO 3 (Nuovo Ticket): // È un nuovo ticket! Inseriamo i default base (Azienda, Negozio, Creatore) final currentUser = _sessionCubit.state.currentStaffMember; final currentStore = _sessionCubit.state.currentStore; diff --git a/lib/features/tickets/data/ticket_repository.dart b/lib/features/tickets/data/ticket_repository.dart index d274e22..ae01cb8 100644 --- a/lib/features/tickets/data/ticket_repository.dart +++ b/lib/features/tickets/data/ticket_repository.dart @@ -170,7 +170,7 @@ class TicketRepository { /// Recupera un ticket specifico CON TUTTE LE RELAZIONI espanse (Cliente e Modelli) /// Questa è la vera magia di Supabase! - Future getTicketWithDetails(String ticketId) async { + Future getTicketById(String ticketId) async { try { // Usiamo i nomi esatti delle Foreign Key che hai definito nell'SQL! final response = await _supabase diff --git a/lib/features/tickets/ui/ticket_form_screen.dart b/lib/features/tickets/ui/ticket_form_screen.dart index 42f2321..6eea21a 100644 --- a/lib/features/tickets/ui/ticket_form_screen.dart +++ b/lib/features/tickets/ui/ticket_form_screen.dart @@ -3,15 +3,16 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart'; import 'package:flux/features/tickets/blocs/ticket_form_state.dart'; import 'package:flux/features/tickets/models/ticket_model.dart'; -import 'package:flux/core/widgets/shared_forms/shared_customer_section.dart'; -import 'package:flux/core/widgets/shared_forms/shared_model_section.dart'; -import 'package:flux/core/widgets/shared_forms/shared_staff_section.dart'; +import 'package:flux/core/widgets/shared_forms/customer_section.dart'; +import 'package:flux/core/widgets/shared_forms/model_section.dart'; +import 'package:flux/core/widgets/shared_forms/staff_section.dart'; import 'package:flux/features/tickets/models/ticket_status_extension.dart'; // Il tuo widget agnostico dello staff class TicketFormScreen extends StatefulWidget { final TicketModel? existingTicket; + final String? ticketId; - const TicketFormScreen({super.key, this.existingTicket}); + const TicketFormScreen({super.key, this.existingTicket, this.ticketId}); @override State createState() => _TicketFormScreenState(); @@ -36,7 +37,10 @@ class _TicketFormScreenState extends State { void initState() { super.initState(); // Inizializziamo il Cubit - context.read().initForm(widget.existingTicket); + context.read().initForm( + id: widget.ticketId, + existingTicket: widget.existingTicket, + ); } @override @@ -150,7 +154,7 @@ class _TicketFormScreenState extends State { padding: const EdgeInsets.only(right: 16.0), child: Chip( label: Text( - ticket.ticketStatus!.name.toUpperCase(), + ticket.ticketStatus.name.toUpperCase(), style: const TextStyle(color: Colors.white, fontSize: 10), ), backgroundColor: ticket.ticketStatus.color, @@ -239,7 +243,7 @@ class _TicketFormScreenState extends State { children: [ Expanded( child: DropdownButtonFormField( - value: ticket.ticketType, + initialValue: ticket.ticketType, decoration: const InputDecoration( labelText: 'Tipo Lavorazione', ), @@ -261,7 +265,7 @@ class _TicketFormScreenState extends State { const SizedBox(width: 16), Expanded( child: DropdownButtonFormField( - value: ticket.ticketStatus, + initialValue: ticket.ticketStatus, decoration: const InputDecoration( labelText: 'Stato Attuale', ), -- 2.43.0 From c8d6d4470c1313e217b83bc0993a06361b9597b5 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Thu, 7 May 2026 10:30:37 +0200 Subject: [PATCH 11/13] aggiunto colore e files a ticket form --- lib/core/routes/app_router.dart | 13 +- .../shared_forms/shared_files_section.dart | 175 +++++ .../tickets/ui/ticket_form_screen.dart | 656 ++++++++++-------- 3 files changed, 557 insertions(+), 287 deletions(-) create mode 100644 lib/core/widgets/shared_forms/shared_files_section.dart diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index 9673721..d89fd99 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -177,8 +177,17 @@ class AppRouter { ); context.read().loadCustomers(); - return BlocProvider( - create: (context) => TicketFormCubit(), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => AttachmentsBloc( + parentType: AttachmentParentType.ticket, + parentId: realTicketId, + ), + ), + BlocProvider(create: (context) => TicketFormCubit()), + ], + child: TicketFormScreen( ticketId: realTicketId, existingTicket: ticketFromExtra, diff --git a/lib/core/widgets/shared_forms/shared_files_section.dart b/lib/core/widgets/shared_forms/shared_files_section.dart new file mode 100644 index 0000000..65d6bec --- /dev/null +++ b/lib/core/widgets/shared_forms/shared_files_section.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/widgets/shared_forms/mobile_upload_screen.dart'; +// Adatta gli import alle tue cartelle reali! +import 'package:flux/features/attachments/blocs/attachments_bloc.dart'; + +class SharedFilesSection extends StatelessWidget { + final String + titleNameForUpload; // Es. il nome del cliente o il modello da passare alla pagina di upload + + const SharedFilesSection({super.key, required this.titleNameForUpload}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Allegati e Foto', + style: TextStyle(fontWeight: FontWeight.bold), + ), + TextButton.icon( + icon: const Icon(Icons.add_a_photo), + label: const Text('Aggiungi'), + onPressed: () { + // Navighiamo verso la nostra fiammante pagina di upload agnostica! + // Assicurati che l'AttachmentsBloc sopravviva al cambio pagina usando BlocProvider.value + final bloc = context.read(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: bloc, + child: SharedMobileUploadScreen( + title: titleNameForUpload, + ), + ), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 8), + + // LA VETRINA DEI FILE + BlocBuilder( + builder: (context, state) { + final files = + state.allFiles; // Unisce sia i remoti che i locali (bozze) + + if (state.status == AttachmentsStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } + + if (files.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border.all( + color: theme.dividerColor, + style: BorderStyle.solid, + ), + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + children: [ + Icon( + Icons.image_not_supported_outlined, + color: Colors.grey, + size: 32, + ), + SizedBox(height: 8), + Text( + 'Nessun file allegato', + style: TextStyle(color: Colors.grey), + ), + ], + ), + ); + } + + return Wrap( + spacing: 12, + runSpacing: 12, + children: files.map((file) { + final isImage = [ + 'jpg', + 'jpeg', + 'png', + 'webp', + ].contains(file.extension.toLowerCase()); + + return Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.dividerColor), + ), + child: Stack( + children: [ + // Sfondo File / Anteprima + Center( + child: isImage + ? const Icon( + Icons.image, + color: Colors.blue, + size: 40, + ) // Qui in futuro metteremo Image.network da Supabase + : const Icon( + Icons.picture_as_pdf, + color: Colors.red, + size: 40, + ), + ), + // Indicatore "Bozza" per i file non ancora caricati + if (file.id == null) + Positioned( + bottom: 4, + left: 4, + right: 4, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: Colors.orange, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'Da salvare', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + // Pulsante Elimina + Positioned( + top: -8, + right: -8, + child: IconButton( + icon: const Icon( + Icons.cancel, + color: Colors.redAccent, + size: 20, + ), + onPressed: () { + // Manda l'evento di eliminazione + context.read().add( + DeleteSpecificAttachmentEvent(file), + ); + }, + ), + ), + ], + ), + ); + }).toList(), + ); + }, + ), + ], + ); + } +} diff --git a/lib/features/tickets/ui/ticket_form_screen.dart b/lib/features/tickets/ui/ticket_form_screen.dart index 6eea21a..a3a2e91 100644 --- a/lib/features/tickets/ui/ticket_form_screen.dart +++ b/lib/features/tickets/ui/ticket_form_screen.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/widgets/shared_forms/customer_section.dart'; +import 'package:flux/core/widgets/shared_forms/model_section.dart'; +import 'package:flux/core/widgets/shared_forms/shared_files_section.dart'; import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart'; import 'package:flux/features/tickets/blocs/ticket_form_state.dart'; import 'package:flux/features/tickets/models/ticket_model.dart'; -import 'package:flux/core/widgets/shared_forms/customer_section.dart'; -import 'package:flux/core/widgets/shared_forms/model_section.dart'; import 'package:flux/core/widgets/shared_forms/staff_section.dart'; -import 'package:flux/features/tickets/models/ticket_status_extension.dart'; // Il tuo widget agnostico dello staff +import 'package:flux/features/tickets/models/ticket_status_extension.dart'; class TicketFormScreen extends StatefulWidget { final TicketModel? existingTicket; @@ -21,7 +22,6 @@ class TicketFormScreen extends StatefulWidget { class _TicketFormScreenState extends State { final _formKey = GlobalKey(); - // Controllers testuali final _altPhoneCtrl = TextEditingController(); final _serialCtrl = TextEditingController(); final _requestCtrl = TextEditingController(); @@ -36,10 +36,9 @@ class _TicketFormScreenState extends State { @override void initState() { super.initState(); - // Inizializziamo il Cubit context.read().initForm( - id: widget.ticketId, existingTicket: widget.existingTicket, + id: widget.ticketId, ); } @@ -56,7 +55,6 @@ class _TicketFormScreenState extends State { super.dispose(); } - // Sincronizza i controller con lo stato iniziale senza sovrascrivere se l'utente sta digitando void _syncTextControllers(TicketModel model) { if (_altPhoneCtrl.text.isEmpty) { _altPhoneCtrl.text = model.alternativePhoneNumber ?? ''; @@ -81,7 +79,6 @@ class _TicketFormScreenState extends State { _isInitialized = true; } - // Chiamato prima del salvataggio per pushare i testi nei campi del Cubit void _flushControllersToCubit() { context.read().updateFields( alternativePhoneNumber: _altPhoneCtrl.text, @@ -121,7 +118,6 @@ class _TicketFormScreenState extends State { content: Text('Scheda salvata! Inserisci la prossima.'), ), ); - // Svuotiamo i controller per il nuovo ticket _altPhoneCtrl.clear(); _serialCtrl.clear(); _requestCtrl.clear(); @@ -149,7 +145,6 @@ class _TicketFormScreenState extends State { ticket.id == null ? 'Nuova Scheda Assistenza' : 'Modifica Scheda', ), actions: [ - // PICCOLO BADGE DI STATO IN ALTO A DESTRA Padding( padding: const EdgeInsets.only(right: 16.0), child: Chip( @@ -164,284 +159,86 @@ class _TicketFormScreenState extends State { ), body: Form( key: _formKey, - child: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Center( - child: ConstrainedBox( - // Limitiamo la larghezza su schermi grandi per non renderlo illeggibile - constraints: const BoxConstraints(maxWidth: 800), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // --- CARD 1: INTESTAZIONE & CLIENTE --- - _buildCard( - title: 'Anagrafica', - icon: Icons.person, - children: [ - // Qui usiamo la StaffSection per scegliere "A nome di chi" apriamo la scheda - StaffSection( - label: 'Creato Da', - staffId: ticket.createdById, - staffName: ticket.createdByName, - onStaffSelected: (staff) => - context.read().updateCreator( - staffId: staff.id!, - staffName: staff.name, - ), - ), - const Divider(height: 32), - SharedCustomerSection( - customerId: ticket.customerId, - customerName: ticket.customerName, - onCustomerSelected: (customer) => context - .read() - .updateCustomer(customer), - ), - const SizedBox(height: 16), - TextFormField( - controller: _altPhoneCtrl, - decoration: const InputDecoration( - labelText: - 'Recapito Alternativo (es. se lascia il telefono principale)', - prefixIcon: Icon(Icons.phone), - ), - ), - ], - ), + // IL TRUCCO PER LA TASTIERA: Obblighiamo il tab a seguire il DOM + child: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: LayoutBuilder( + builder: (context, constraints) { + final isUltraWide = constraints.maxWidth > 1400; + final isDesktop = constraints.maxWidth > 900; - // --- CARD 2: DISPOSITIVO --- - _buildCard( - title: 'Dispositivo', - icon: Icons.devices, - children: [ - SharedModelSection( - label: 'Modello da Riparare', - modelId: ticket.targetModelId, - modelName: ticket.targetModelName, - onModelSelected: (id, name) => context - .read() - .updateModel(modelId: id, modelName: name), - ), - const SizedBox(height: 16), - TextFormField( - controller: _serialCtrl, - decoration: const InputDecoration( - labelText: 'Seriale / IMEI', - prefixIcon: Icon(Icons.qr_code), - ), - ), - ], + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isUltraWide + ? 1600 + : (isDesktop ? 1200 : 800), + ), + child: _buildResponsiveLayout( + isUltraWide, + isDesktop, + ticket, + ), ), - - // --- CARD 3: PROBLEMA E LAVORAZIONE --- - _buildCard( - title: 'Dettagli Riparazione', - icon: Icons.build, - children: [ - // Tipo Lavorazione e Tipo Garanzia - Row( - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: ticket.ticketType, - decoration: const InputDecoration( - labelText: 'Tipo Lavorazione', - ), - items: TicketType.values - .map( - (t) => DropdownMenuItem( - value: t, - child: Text( - t.name, - ), // Se hai estensioni, usa t.displayName - ), - ) - .toList(), - onChanged: (val) => context - .read() - .updateFields(ticketType: val), - ), - ), - const SizedBox(width: 16), - Expanded( - child: DropdownButtonFormField( - initialValue: ticket.ticketStatus, - decoration: const InputDecoration( - labelText: 'Stato Attuale', - ), - items: TicketStatus.values - .map( - (s) => DropdownMenuItem( - value: s, - child: Text(s.name), // Idem qui - ), - ) - .toList(), - onChanged: (val) => context - .read() - .updateFields(status: val), - ), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - controller: _requestCtrl, - maxLines: 4, - decoration: const InputDecoration( - labelText: - 'Difetto dichiarato o Richiesta del cliente', - alignLabelWithHint: true, - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _accessoriesCtrl, - decoration: const InputDecoration( - labelText: - 'Accessori Consegnati (es. cover, caricatore...)', - prefixIcon: Icon(Icons.cable), - ), - ), - const SizedBox(height: 16), - SwitchListTile( - title: const Text('Prestato Telefono di Cortesia?'), - value: ticket.hasCourtesyDevice, - onChanged: (val) => context - .read() - .updateFields(hasCourtesyDevice: val), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: BorderSide(color: theme.dividerColor), - ), - ), - ], - ), - - // --- CARD 4: COSTI E NOTE --- - _buildCard( - title: 'Costi & Note', - icon: Icons.euro, - children: [ - Row( - children: [ - Expanded( - child: TextFormField( - controller: _priceCtrl, - keyboardType: - const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration( - labelText: 'Preventivo Cliente (€)', - prefixIcon: Icon(Icons.sell_outlined), - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: TextFormField( - controller: _costCtrl, - keyboardType: - const TextInputType.numberWithOptions( - decimal: true, - ), - decoration: const InputDecoration( - labelText: 'Nostro Costo (€)', - prefixIcon: Icon( - Icons.shopping_cart_outlined, - ), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - TextFormField( - controller: _publicNotesCtrl, - maxLines: 2, - decoration: const InputDecoration( - labelText: - 'Note Pubbliche (Visibili su ricevuta)', - ), - ), - const SizedBox(height: 16), - TextFormField( - controller: _internalNotesCtrl, - maxLines: 3, - decoration: InputDecoration( - labelText: 'Note Interne (Solo per lo Staff)', - fillColor: Colors.amber.withValues(alpha: 0.1), - filled: true, - ), - ), - ], - ), - - // --- CARD 5: ASSEGNAZIONE & FILE --- - _buildCard( - title: 'Assegnazione Tecnico', - icon: Icons.engineering, - children: [ - StaffSection( - label: 'Assegnato A', - staffId: ticket.assignedToId, - staffName: ticket.assignedToName, - onStaffSelected: (staff) => - context.read().updateFields( - assignedToId: staff.id, - assignedToName: staff.name, - ), - ), - // TODO: Inserire qui il tuo SharedAttachmentsSection per foto pre-riparazione - ], - ), - - const SizedBox(height: 80), // Spazio per il bottom nav - ], - ), - ), + ), + ); + }, ), ), ), bottomNavigationBar: SafeArea( - child: Padding( + child: Container( padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Expanded( - flex: 1, - child: OutlinedButton( - onPressed: state.status == TicketFormStatus.saving - ? null - : () => _saveTicket(keepAdding: true), - child: const Text( - 'Salva e Aggiungi Altro', - textAlign: TextAlign.center, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 1, - child: ElevatedButton( - onPressed: state.status == TicketFormStatus.saving - ? null - : () => _saveTicket(keepAdding: false), - child: state.status == TicketFormStatus.saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, - ), - ) - : const Text('Salva ed Esci'), - ), + decoration: BoxDecoration( + color: theme.scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -3), ), ], ), + child: FocusTraversalGroup( + // Un gruppo a parte per il footer, così viene visitato per ultimo + child: Row( + children: [ + Expanded( + flex: 1, + child: OutlinedButton( + onPressed: state.status == TicketFormStatus.saving + ? null + : () => _saveTicket(keepAdding: true), + child: const Text( + 'Salva e Aggiungi Altro', + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 1, + child: ElevatedButton( + onPressed: state.status == TicketFormStatus.saving + ? null + : () => _saveTicket(keepAdding: false), + child: state.status == TicketFormStatus.saving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text('Salva ed Esci'), + ), + ), + ], + ), + ), ), ), ); @@ -449,16 +246,296 @@ class _TicketFormScreenState extends State { ); } - // Helper per creare le Card esteticamente coerenti + // --- LOGICA DI IMPAGINAZIONE RESPONSIVE --- + Widget _buildResponsiveLayout( + bool isUltraWide, + bool isDesktop, + TicketModel ticket, + ) { + if (isUltraWide) { + // 3 COLONNE + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [_cardAnagrafica(ticket), _cardDispositivo(ticket)], + ), + ), + const SizedBox(width: 24), + Expanded(child: Column(children: [_cardDettagli(ticket)])), + const SizedBox(width: 24), + Expanded( + child: Column( + children: [_cardCosti(ticket), _cardAssegnazione(ticket)], + ), + ), + ], + ); + } else if (isDesktop) { + // 2 COLONNE + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + _cardAnagrafica(ticket), + _cardDispositivo(ticket), + _cardAssegnazione(ticket), + ], + ), + ), + const SizedBox(width: 24), + Expanded( + child: Column( + children: [_cardDettagli(ticket), _cardCosti(ticket)], + ), + ), + ], + ); + } else { + // 1 COLONNA (Mobile) + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _cardAnagrafica(ticket), + _cardDispositivo(ticket), + _cardDettagli(ticket), + _cardCosti(ticket), + _cardAssegnazione(ticket), + ], + ); + } + } + + // --- LE 5 CARD (MODULARIZZATE E COLORATE) --- + + Widget _cardAnagrafica(TicketModel ticket) { + return _buildCard( + title: 'Anagrafica', + icon: Icons.person, + themeColor: Colors.indigo, + children: [ + StaffSection( + label: 'Creato Da', + staffId: ticket.createdById, + staffName: ticket.createdByName, + onStaffSelected: (staff) => context + .read() + .updateCreator(staffId: staff.id!, staffName: staff.name), + ), + const Divider(height: 32), + SharedCustomerSection( + customerId: ticket.customerId, + customerName: ticket.customerName, + onCustomerSelected: (customer) => + context.read().updateCustomer(customer), + ), + const SizedBox(height: 16), + TextFormField( + controller: _altPhoneCtrl, + decoration: const InputDecoration( + labelText: 'Recapito Alternativo', + prefixIcon: Icon(Icons.phone), + ), + ), + ], + ); + } + + Widget _cardDispositivo(TicketModel ticket) { + return _buildCard( + title: 'Dispositivo', + icon: Icons.devices, + themeColor: Colors.deepOrange, + children: [ + SharedModelSection( + label: 'Modello da Riparare', + modelId: ticket.targetModelId, + modelName: ticket.targetModelName, + onModelSelected: (id, name) => context + .read() + .updateModel(modelId: id, modelName: name), + ), + const SizedBox(height: 16), + TextFormField( + controller: _serialCtrl, + decoration: const InputDecoration( + labelText: 'Seriale / IMEI', + prefixIcon: Icon(Icons.qr_code), + ), + ), + ], + ); + } + + Widget _cardDettagli(TicketModel ticket) { + return _buildCard( + title: 'Dettagli Riparazione', + icon: Icons.build, + themeColor: Colors.pink, + children: [ + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: ticket.ticketType, + decoration: const InputDecoration( + labelText: 'Tipo Lavorazione', + ), + items: TicketType.values + .map((t) => DropdownMenuItem(value: t, child: Text(t.name))) + .toList(), + onChanged: (val) => context + .read() + .updateFields(ticketType: val), + ), + ), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + initialValue: ticket.ticketStatus, + decoration: const InputDecoration(labelText: 'Stato Attuale'), + items: TicketStatus.values + .map((s) => DropdownMenuItem(value: s, child: Text(s.name))) + .toList(), + onChanged: (val) => + context.read().updateFields(status: val), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _requestCtrl, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Difetto dichiarato o Richiesta del cliente', + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _accessoriesCtrl, + decoration: const InputDecoration( + labelText: 'Accessori Consegnati', + prefixIcon: Icon(Icons.cable), + ), + ), + const SizedBox(height: 16), + SwitchListTile( + title: const Text('Prestato Telefono di Cortesia?'), + value: ticket.hasCourtesyDevice, + onChanged: (val) => context.read().updateFields( + hasCourtesyDevice: val, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide(color: Theme.of(context).dividerColor), + ), + ), + ], + ); + } + + Widget _cardCosti(TicketModel ticket) { + return _buildCard( + title: 'Costi & Note', + icon: Icons.euro, + themeColor: Colors.teal, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _priceCtrl, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Preventivo Cliente (€)', + prefixIcon: Icon(Icons.sell_outlined), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _costCtrl, + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + decoration: const InputDecoration( + labelText: 'Nostro Costo (€)', + prefixIcon: Icon(Icons.shopping_cart_outlined), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _publicNotesCtrl, + maxLines: 2, + decoration: const InputDecoration( + labelText: 'Note Pubbliche (Visibili su ricevuta)', + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _internalNotesCtrl, + maxLines: 3, + decoration: InputDecoration( + labelText: 'Note Interne (Solo per lo Staff)', + fillColor: Colors.amber.withValues(alpha: 0.1), + filled: true, + ), + ), + ], + ); + } + + Widget _cardAssegnazione(TicketModel ticket) { + return _buildCard( + title: 'Assegnazione e Allegati', + icon: Icons.engineering, + themeColor: Colors.deepPurple, + children: [ + StaffSection( + label: 'Assegnato A', + staffId: ticket.assignedToId, + staffName: ticket.assignedToName, + onStaffSelected: (staff) => context + .read() + .updateFields(assignedToId: staff.id, assignedToName: staff.name), + ), + const Divider(height: 32), + // ECCO LA MAGIA: + SharedFilesSection( + titleNameForUpload: ticket.customerName ?? 'Nuovo Ticket', + ), + ], + ); + } + + // --- WIDGET BASE PER LA CARD --- Widget _buildCard({ required String title, required IconData icon, + required Color themeColor, required List children, }) { return Card( margin: const EdgeInsets.only(bottom: 24), - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0, // Tolta l'ombra standard + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: themeColor.withValues(alpha: 0.3), + width: 1, + ), // Bordo colorato delicato + ), child: Padding( padding: const EdgeInsets.all(20.0), child: Column( @@ -466,13 +543,22 @@ class _TicketFormScreenState extends State { children: [ Row( children: [ - Icon(icon, color: Theme.of(context).colorScheme.primary), + // Pallino colorato con l'icona dentro + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: themeColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: themeColor), + ), const SizedBox(width: 12), Text( title, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: themeColor, + ), ), ], ), -- 2.43.0 From 0af51aae10e4d1d38c2169fbcdcdd6a1e10c6fa5 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Thu, 7 May 2026 13:28:41 +0200 Subject: [PATCH 12/13] fg --- android/app/src/main/AndroidManifest.xml | 4 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- lib/core/routes/app_router.dart | 29 ++++- .../shared_forms/shared_files_section.dart | 107 +++++++++++++----- .../data/attachments_repository.dart | 2 +- .../tickets/blocs/ticket_form_cubit.dart | 28 +++++ .../tickets/ui/ticket_form_screen.dart | 25 ++++ 7 files changed, 164 insertions(+), 33 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b24b47c..2ffc11b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -24,11 +24,11 @@ - + - +