From b5ccb0428d4a471853be56027dc88a5726431ebd Mon Sep 17 00:00:00 2001 From: mark-cachy Date: Fri, 15 May 2026 19:18:03 +0200 Subject: [PATCH] fdsg --- .../data/tickets_shipment_repository.dart | 67 ++++ .../models/shipment_document_model.dart | 39 ++- .../providers/ui/provider_list_screen.dart | 4 +- .../models/document_sequence_model.dart | 2 +- .../tickets/blocs/ticket_shipping_cubit.dart | 157 ++++++++++ .../tickets/blocs/ticket_shipping_state.dart | 51 +++ .../tickets/ui/ticket_list_screen.dart | 290 +----------------- .../tickets/ui/widgets/ticket_list.dart | 106 +++++++ .../tickets/ui/widgets/ticket_list_card.dart | 198 ++++++++++++ 9 files changed, 620 insertions(+), 294 deletions(-) create mode 100644 lib/features/documents/data/tickets_shipment_repository.dart create mode 100644 lib/features/tickets/blocs/ticket_shipping_cubit.dart create mode 100644 lib/features/tickets/blocs/ticket_shipping_state.dart create mode 100644 lib/features/tickets/ui/widgets/ticket_list.dart create mode 100644 lib/features/tickets/ui/widgets/ticket_list_card.dart diff --git a/lib/features/documents/data/tickets_shipment_repository.dart b/lib/features/documents/data/tickets_shipment_repository.dart new file mode 100644 index 0000000..3188219 --- /dev/null +++ b/lib/features/documents/data/tickets_shipment_repository.dart @@ -0,0 +1,67 @@ +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/features/documents/models/shipment_document_model.dart'; +import 'package:flux/features/master_data/providers/models/provider_model.dart'; +import 'package:flux/features/master_data/providers/models/provider_role.dart'; +import 'package:flux/features/settings/document_sequence/models/document_sequence_model.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +class TicketsShipmentRepository { + final _supabase = GetIt.I.get(); + final _companyId = GetIt.I.get().state.company!.id!; + + Future> fetchRepairCenters() async { + try { + final response = await _supabase + .from('provider') + .select('*, provider_locations (*)') + .eq('is_active', true) + .order('name'); + + final allProviders = (response as List) + .map((row) => ProviderModel.fromMap(row as Map)) + .toList(); + + // Filtriamo lato client per prendere SOLO i repairCenter + return allProviders + .where((p) => p.roles.contains(ProviderRole.repairCenter)) + .toList(); + } catch (e) { + throw ('Errore caricamento laboratori: $e'); + } + } + + // NUOVO METODO: Salva il DDT e aggiorna i Ticket + Future createShipmentDocument({ + required ShipmentDocumentModel document, + required String newTicketStatus, // es: 'shipped' o 'inExternalLab' + }) async { + try { + // 1. Inseriamo il singolo Documento di Trasporto + await _supabase.from('shipment_documents').insert(document.toMap()); + + // 2. Aggiorniamo lo stato di TUTTI i ticket inclusi nel DDT + await _supabase + .from('tickets') + .update({'ticket_status': newTicketStatus}) + .inFilter('id', document.ticketIds); + } catch (e) { + throw ('Errore durante la creazione della spedizione: $e'); + } + } + + Future getNextAutoDocumentNumber() async { + try { + final response = await _supabase + .from('document_sequences') + .select('*') + .eq('company_id', _companyId) + .eq('document_type', DocumentType.shipment.name) + .single(); + + return DocumentSequence.fromMap(response); + } catch (e) { + throw ('Errore recupero numero documento: $e'); + } + } +} diff --git a/lib/features/documents/models/shipment_document_model.dart b/lib/features/documents/models/shipment_document_model.dart index 2c1cdba..c9b2905 100644 --- a/lib/features/documents/models/shipment_document_model.dart +++ b/lib/features/documents/models/shipment_document_model.dart @@ -3,7 +3,7 @@ import 'package:equatable/equatable.dart'; class ShipmentDocumentModel extends Equatable { final String? id; final String companyId; - final String ticketId; + final List ticketIds; final String providerId; final String destinationLocationId; final String docNumber; @@ -16,7 +16,7 @@ class ShipmentDocumentModel extends Equatable { const ShipmentDocumentModel({ this.id, required this.companyId, - required this.ticketId, + required this.ticketIds, required this.providerId, required this.destinationLocationId, required this.docNumber, @@ -27,11 +27,40 @@ class ShipmentDocumentModel extends Equatable { this.notes, }); + ShipmentDocumentModel copyWith({ + String? id, + String? companyId, + List? ticketIds, + String? providerId, + String? destinationLocationId, + String? docNumber, + DateTime? docDate, + int? packageCount, + double? weight, + String? shippingReason, + String? notes, + }) { + return ShipmentDocumentModel( + id: id ?? this.id, + companyId: companyId ?? this.companyId, + ticketIds: ticketIds ?? this.ticketIds, + providerId: providerId ?? this.providerId, + destinationLocationId: + destinationLocationId ?? this.destinationLocationId, + docNumber: docNumber ?? this.docNumber, + docDate: docDate ?? this.docDate, + packageCount: packageCount ?? this.packageCount, + weight: weight ?? this.weight, + shippingReason: shippingReason ?? this.shippingReason, + notes: notes ?? this.notes, + ); + } + factory ShipmentDocumentModel.fromMap(Map map) { return ShipmentDocumentModel( id: map['id'], companyId: map['company_id'], - ticketId: map['ticket_id'], + ticketIds: List.from(map['ticket_ids']), providerId: map['provider_id'], destinationLocationId: map['destination_location_id'], docNumber: map['doc_number'], @@ -47,7 +76,7 @@ class ShipmentDocumentModel extends Equatable { return { if (id != null) 'id': id, 'company_id': companyId, - 'ticket_id': ticketId, + 'ticket_ids': ticketIds, 'provider_id': providerId, 'destination_location_id': destinationLocationId, 'doc_number': docNumber, @@ -60,5 +89,5 @@ class ShipmentDocumentModel extends Equatable { } @override - List get props => [id, docNumber, ticketId]; + List get props => [id, docNumber, ticketIds]; } diff --git a/lib/features/master_data/providers/ui/provider_list_screen.dart b/lib/features/master_data/providers/ui/provider_list_screen.dart index 0b96973..f9a906f 100644 --- a/lib/features/master_data/providers/ui/provider_list_screen.dart +++ b/lib/features/master_data/providers/ui/provider_list_screen.dart @@ -89,7 +89,7 @@ class _ProviderListScreenState extends State { vertical: 8, ), itemCount: filterChipsWidgets.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), + separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (context, index) => filterChipsWidgets[index], ), ), @@ -125,7 +125,7 @@ class _ProviderListScreenState extends State { return ListView.separated( itemCount: displayList.length, - separatorBuilder: (_, __) => const Divider(height: 1), + separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final provider = displayList[index]; diff --git a/lib/features/settings/document_sequence/models/document_sequence_model.dart b/lib/features/settings/document_sequence/models/document_sequence_model.dart index c596674..2d3f8b9 100644 --- a/lib/features/settings/document_sequence/models/document_sequence_model.dart +++ b/lib/features/settings/document_sequence/models/document_sequence_model.dart @@ -1,4 +1,4 @@ -enum DocumentType { ticket, ddt, invoice } +enum DocumentType { ticket, shipment, invoice } class DocumentSequence { final String docType; diff --git a/lib/features/tickets/blocs/ticket_shipping_cubit.dart b/lib/features/tickets/blocs/ticket_shipping_cubit.dart new file mode 100644 index 0000000..ce96ae4 --- /dev/null +++ b/lib/features/tickets/blocs/ticket_shipping_cubit.dart @@ -0,0 +1,157 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/features/documents/data/tickets_shipment_repository.dart'; +import 'package:flux/features/documents/models/shipment_document_model.dart'; +import 'package:flux/features/master_data/providers/models/provider_location_model.dart'; +import 'package:flux/features/master_data/providers/models/provider_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'ticket_shipping_state.dart'; + +class TicketShippingCubit extends Cubit { + final TicketsShipmentRepository _repository = + GetIt.I(); + TicketShippingCubit({required List ticketIds}) + : super( + TicketShippingState( + // Inizializziamo il modello direttamente nello stato! + document: ShipmentDocumentModel( + companyId: GetIt.I.get().state.company!.id!, + ticketIds: ticketIds, + providerId: '', // Sarà riempito alla selezione + destinationLocationId: '', // Sarà riempito alla selezione + docNumber: '', + docDate: DateTime.now(), + ), + ), + ); + + Future loadRepairCenters() async { + emit(state.copyWith(status: TicketShippingStatus.loading)); + try { + final repairCenters = await _repository.fetchRepairCenters(); + emit( + state.copyWith( + status: TicketShippingStatus.initial, + availableProviders: repairCenters, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: TicketShippingStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void selectProvider(String providerId) { + final ProviderModel provider = state.availableProviders.firstWhere( + (p) => p.id == providerId, + ); + final locations = provider.locations ?? []; + + String destinationId = ''; + if (locations.length == 1) { + destinationId = locations.first.id ?? ''; + } else if (locations.any((l) => l.isMain)) { + destinationId = locations.firstWhere((l) => l.isMain).id ?? ''; + } + + emit( + state.copyWith( + availableLocations: locations, + document: state.document.copyWith( + providerId: providerId, + destinationLocationId: destinationId, + ), + ), + ); + } + + void selectLocation(String locationId) { + emit( + state.copyWith( + document: state.document.copyWith(destinationLocationId: locationId), + ), + ); + } + + void toggleAutoNumber(bool value) { + emit(state.copyWith(isAutoNumber: value)); + if (value) { + final nextNumber = "DDT-${DateTime.now().year}-001"; + emit( + state.copyWith( + document: state.document.copyWith(docNumber: nextNumber), + ), + ); + } else { + emit(state.copyWith(document: state.document.copyWith(docNumber: ''))); + } + } + + // Metodo unico e pulito per aggiornare i campi testuali/numerici del documento + void updateDocument({ + String? docNumber, + DateTime? docDate, + int? packageCount, + double? weight, + String? shippingReason, + String? notes, + }) { + emit( + state.copyWith( + document: state.document.copyWith( + docNumber: docNumber, + docDate: docDate, + packageCount: packageCount, + weight: weight, + shippingReason: shippingReason, + notes: notes, + ), + ), + ); + } + + Future confirmShipment({required String newTicketStatus}) async { + if (state.document.providerId.isEmpty || + state.document.destinationLocationId.isEmpty) { + emit( + state.copyWith( + status: TicketShippingStatus.failure, + errorMessage: 'Seleziona laboratorio e sede.', + ), + ); + return; + } + if (state.document.docNumber.trim().isEmpty) { + emit( + state.copyWith( + status: TicketShippingStatus.failure, + errorMessage: 'Inserisci il numero DDT.', + ), + ); + return; + } + + emit(state.copyWith(status: TicketShippingStatus.loading)); + + try { + await _repository.createShipmentDocument( + document: state.document, + newTicketStatus: newTicketStatus, + ); + emit(state.copyWith(status: TicketShippingStatus.success)); + } catch (e) { + emit( + state.copyWith( + status: TicketShippingStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/features/tickets/blocs/ticket_shipping_state.dart b/lib/features/tickets/blocs/ticket_shipping_state.dart new file mode 100644 index 0000000..a513645 --- /dev/null +++ b/lib/features/tickets/blocs/ticket_shipping_state.dart @@ -0,0 +1,51 @@ +part of 'ticket_shipping_cubit.dart'; + +enum TicketShippingStatus { initial, loading, success, failure } + +class TicketShippingState extends Equatable { + final TicketShippingStatus status; + final ShipmentDocumentModel document; // Il nostro eroe! + + // Dati di supporto per la UI + final List availableProviders; + final List availableLocations; + final bool isAutoNumber; + final String? errorMessage; + + const TicketShippingState({ + this.status = TicketShippingStatus.initial, + required this.document, + this.availableProviders = const [], + this.availableLocations = const [], + this.isAutoNumber = false, + this.errorMessage, + }); + + TicketShippingState copyWith({ + TicketShippingStatus? status, + ShipmentDocumentModel? document, + List? availableProviders, + List? availableLocations, + bool? isAutoNumber, + String? errorMessage, + }) { + return TicketShippingState( + status: status ?? this.status, + document: document ?? this.document, + availableProviders: availableProviders ?? this.availableProviders, + availableLocations: availableLocations ?? this.availableLocations, + isAutoNumber: isAutoNumber ?? this.isAutoNumber, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + document, + availableProviders, + availableLocations, + isAutoNumber, + errorMessage, + ]; +} diff --git a/lib/features/tickets/ui/ticket_list_screen.dart b/lib/features/tickets/ui/ticket_list_screen.dart index 70379e9..2c3160c 100644 --- a/lib/features/tickets/ui/ticket_list_screen.dart +++ b/lib/features/tickets/ui/ticket_list_screen.dart @@ -7,6 +7,7 @@ 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'; +import 'package:flux/features/tickets/ui/widgets/ticket_list.dart'; import 'package:go_router/go_router.dart'; class TicketListScreen extends StatefulWidget { @@ -124,98 +125,9 @@ class _TicketListScreenState extends State { return const Center(child: Text('Nessun ticket trovato.')); } - return Stack( - children: [ - 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]; - final isSelected = state.selectedTicketIds.contains( - ticket.id, - ); - final isSelectionMode = - state.selectedTicketIds.isNotEmpty; - - // Per Desktop mostriamo la checkbox vera e propria - final isDesktop = - MediaQuery.of(context).size.width > 800; - return _TicketCard( - ticket: ticket, - isSelected: isSelected, - isSelectionMode: isSelectionMode, - isDesktop: isDesktop, - ); - }, - ), - - // 2. LA BARRA DELLE AZIONI BULK (Appare magicamente dal basso) - AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - bottom: state.selectedTicketIds.isNotEmpty - ? 90 - : -100, // Nasconde o mostra - left: 16, - right: 16, - child: Card( - elevation: 8, - color: Theme.of(context).colorScheme.inverseSurface, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.close), - onPressed: () => context - .read() - .clearSelection(), - ), - Text( - '${state.selectedTicketIds.length} selezionati', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const Spacer(), - - // IL NOSTRO FAMOSO BOTTONE SPEDISCI - FilledButton.icon( - onPressed: () { - // Qui lanceremo la modale per creare il DDT - // passando la lista: state.selectedTicketIds.toList() - print( - "Spedisco i ticket: ${state.selectedTicketIds}", - ); - }, - icon: const Icon(Icons.local_shipping), - label: const Text('Spedisci'), - ), - ], - ), - ), - ), - ), - ], + return TicketList( + state: state, + scrollController: _scrollController, ); }, ), @@ -266,197 +178,3 @@ class _TicketListScreenState extends State { ); } } - -// --------------------------------------------------------- -// LA CARD DEL TICKET (Il "Colpo d'Occhio") -// --------------------------------------------------------- -class _TicketCard extends StatelessWidget { - final TicketModel ticket; - final bool isSelected; - final bool isSelectionMode; - final bool isDesktop; - - const _TicketCard({ - super.key, // <-- Buona pratica aggiungere il super.key - required this.ticket, - required this.isSelected, - required this.isSelectionMode, - required this.isDesktop, - }); - - @override - Widget build(BuildContext context) { - final statusColor = ticket.ticketStatus.color; - final statusIcon = ticket.ticketStatus.icon; - - // Tocco Ninja: Ricaviamo l'iniziale del cliente per l'avatar! - final customerName = ticket.customer?.name ?? '?'; - final initial = customerName.isNotEmpty - ? customerName[0].toUpperCase() - : '?'; - - return Card( - // 1. Sfondo leggermente colorato se selezionato - color: isSelected ? Colors.blue.withValues(alpha: 0.1) : null, - margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - clipBehavior: Clip.antiAlias, - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // LA STRISCIA COLORATA LATERALE (Intoccabile, è bellissima) - Container(width: 6, color: statusColor), - Center( - child: Padding( - padding: const EdgeInsets.only(left: 16.0), - child: isDesktop - ? Checkbox( - value: isSelected, - onChanged: (_) { - if (ticket.id != null) { - context - .read() - .toggleTicketSelection(ticket.id!); - } - }, - ) - : GestureDetector( - onTap: () { - if (ticket.id != null) { - context - .read() - .toggleTicketSelection(ticket.id!); - } - }, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) => - ScaleTransition(scale: animation, child: child), - child: isSelected - ? const CircleAvatar( - key: ValueKey('selected'), - backgroundColor: Colors.blue, - child: Icon(Icons.check, color: Colors.white), - ) - : CircleAvatar( - key: const ValueKey('unselected'), - backgroundColor: Colors.grey.shade200, - child: Text( - initial, // L'iniziale del cliente! - style: TextStyle( - color: Colors.grey.shade700, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ), - ), - ), - Expanded( - child: ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - - // ---------------------------------------- - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - ticket.customer?.name ?? 'Cliente Sconosciuto', - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - overflow: TextOverflow.ellipsis, - ), - ), - // IL BADGE DELLO STATO - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: statusColor.withValues(alpha: 0.5), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(statusIcon, size: 14, color: statusColor), - const SizedBox(width: 4), - Text( - ticket.ticketStatus.displayValue, - style: TextStyle( - fontSize: 12, - color: statusColor, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text( - ticket.targetModelName ?? ticket.ticketType.displayValue, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - const SizedBox(height: 4), - Text( - ticket.createdAt != null - ? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}' - : '', - style: TextStyle( - color: Colors.grey.shade600, - fontSize: 12, - ), - ), - ], - ), - - // --- 3. GESTIONE DEI TAP PER LA SELEZIONE --- - onTap: () { - if (isSelectionMode) { - // Modalità selezione attiva: un tap normale seleziona/deseleziona - if (ticket.id != null) { - context.read().toggleTicketSelection( - ticket.id!, - ); - } - } else { - // Modalità normale: entra nel dettaglio del ticket - context.pushNamed( - Routes.ticketForm, - pathParameters: {'id': ticket.id!}, - extra: (ticket: ticket, createdBy: null), - ); - } - }, - onLongPress: () { - // Pressione lunga: forza la selezione (utile per iniziare il workflow) - if (!isSelectionMode && ticket.id != null) { - context.read().toggleTicketSelection( - ticket.id!, - ); - } - }, - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/tickets/ui/widgets/ticket_list.dart b/lib/features/tickets/ui/widgets/ticket_list.dart new file mode 100644 index 0000000..fee0f64 --- /dev/null +++ b/lib/features/tickets/ui/widgets/ticket_list.dart @@ -0,0 +1,106 @@ +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/ui/widgets/ticket_list_card.dart'; + +class TicketList extends StatelessWidget { + final ScrollController scrollController; + final TicketListState state; + + const TicketList({ + super.key, + required this.scrollController, + required this.state, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + 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]; + final isSelected = state.selectedTicketIds.contains(ticket.id); + final isSelectionMode = state.selectedTicketIds.isNotEmpty; + + // Per Desktop mostriamo la checkbox vera e propria + final isDesktop = MediaQuery.of(context).size.width > 800; + return TicketListCard( + ticket: ticket, + isSelected: isSelected, + isSelectionMode: isSelectionMode, + isDesktop: isDesktop, + ); + }, + ), + + // 2. LA BARRA DELLE AZIONI BULK (Appare magicamente dal basso) + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + bottom: state.selectedTicketIds.isNotEmpty + ? 90 + : -100, // Nasconde o mostra + left: 16, + right: 16, + child: Card( + elevation: 8, + color: Theme.of(context).colorScheme.inverseSurface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: () => + context.read().clearSelection(), + ), + Text( + '${state.selectedTicketIds.length} selezionati', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const Spacer(), + + // IL NOSTRO FAMOSO BOTTONE SPEDISCI + FilledButton.icon( + onPressed: () { + // Qui lanceremo la modale per creare il DDT + // passando la lista: state.selectedTicketIds.toList() + print("Spedisco i ticket: ${state.selectedTicketIds}"); + }, + icon: const Icon(Icons.local_shipping), + label: const Text('Spedisci'), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/tickets/ui/widgets/ticket_list_card.dart b/lib/features/tickets/ui/widgets/ticket_list_card.dart new file mode 100644 index 0000000..d03aadf --- /dev/null +++ b/lib/features/tickets/ui/widgets/ticket_list_card.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/routes/routes.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/models/ticket_status_extension.dart'; +import 'package:go_router/go_router.dart'; + +class TicketListCard extends StatelessWidget { + final TicketModel ticket; + final bool isSelected; + final bool isSelectionMode; + final bool isDesktop; + + const TicketListCard({ + super.key, // <-- Buona pratica aggiungere il super.key + required this.ticket, + required this.isSelected, + required this.isSelectionMode, + required this.isDesktop, + }); + + @override + Widget build(BuildContext context) { + final statusColor = ticket.ticketStatus.color; + final statusIcon = ticket.ticketStatus.icon; + + // Tocco Ninja: Ricaviamo l'iniziale del cliente per l'avatar! + final customerName = ticket.customer?.name ?? '?'; + final initial = customerName.isNotEmpty + ? customerName[0].toUpperCase() + : '?'; + + return Card( + // 1. Sfondo leggermente colorato se selezionato + color: isSelected ? Colors.blue.withValues(alpha: 0.1) : null, + margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + clipBehavior: Clip.antiAlias, + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // LA STRISCIA COLORATA LATERALE (Intoccabile, è bellissima) + Container(width: 6, color: statusColor), + Center( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: isDesktop + ? Checkbox( + value: isSelected, + onChanged: (_) { + if (ticket.id != null) { + context + .read() + .toggleTicketSelection(ticket.id!); + } + }, + ) + : GestureDetector( + onTap: () { + if (ticket.id != null) { + context + .read() + .toggleTicketSelection(ticket.id!); + } + }, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) => + ScaleTransition(scale: animation, child: child), + child: isSelected + ? const CircleAvatar( + key: ValueKey('selected'), + backgroundColor: Colors.blue, + child: Icon(Icons.check, color: Colors.white), + ) + : CircleAvatar( + key: const ValueKey('unselected'), + backgroundColor: Colors.grey.shade200, + child: Text( + initial, // L'iniziale del cliente! + style: TextStyle( + color: Colors.grey.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ), + ), + Expanded( + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + + // ---------------------------------------- + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + ticket.customer?.name ?? 'Cliente Sconosciuto', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + overflow: TextOverflow.ellipsis, + ), + ), + // IL BADGE DELLO STATO + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: statusColor.withValues(alpha: 0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statusIcon, size: 14, color: statusColor), + const SizedBox(width: 4), + Text( + ticket.ticketStatus.displayValue, + style: TextStyle( + fontSize: 12, + color: statusColor, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + ticket.targetModelName ?? ticket.ticketType.displayValue, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 4), + Text( + ticket.createdAt != null + ? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}' + : '', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ], + ), + + // --- 3. GESTIONE DEI TAP PER LA SELEZIONE --- + onTap: () { + if (isSelectionMode) { + // Modalità selezione attiva: un tap normale seleziona/deseleziona + if (ticket.id != null) { + context.read().toggleTicketSelection( + ticket.id!, + ); + } + } else { + // Modalità normale: entra nel dettaglio del ticket + context.pushNamed( + Routes.ticketForm, + pathParameters: {'id': ticket.id!}, + extra: (ticket: ticket, createdBy: null), + ); + } + }, + onLongPress: () { + // Pressione lunga: forza la selezione (utile per iniziare il workflow) + if (!isSelectionMode && ticket.id != null) { + context.read().toggleTicketSelection( + ticket.id!, + ); + } + }, + ), + ), + ], + ), + ), + ); + } +}