This commit is contained in:
2026-05-15 19:18:03 +02:00
parent f4a8314978
commit b5ccb0428d
9 changed files with 620 additions and 294 deletions

View File

@@ -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<SupabaseClient>();
final _companyId = GetIt.I.get<SessionCubit>().state.company!.id!;
Future<List<ProviderModel>> 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<String, dynamic>))
.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<void> 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<String> 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');
}
}
}

View File

@@ -3,7 +3,7 @@ import 'package:equatable/equatable.dart';
class ShipmentDocumentModel extends Equatable { class ShipmentDocumentModel extends Equatable {
final String? id; final String? id;
final String companyId; final String companyId;
final String ticketId; final List<String> ticketIds;
final String providerId; final String providerId;
final String destinationLocationId; final String destinationLocationId;
final String docNumber; final String docNumber;
@@ -16,7 +16,7 @@ class ShipmentDocumentModel extends Equatable {
const ShipmentDocumentModel({ const ShipmentDocumentModel({
this.id, this.id,
required this.companyId, required this.companyId,
required this.ticketId, required this.ticketIds,
required this.providerId, required this.providerId,
required this.destinationLocationId, required this.destinationLocationId,
required this.docNumber, required this.docNumber,
@@ -27,11 +27,40 @@ class ShipmentDocumentModel extends Equatable {
this.notes, this.notes,
}); });
ShipmentDocumentModel copyWith({
String? id,
String? companyId,
List<String>? 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<String, dynamic> map) { factory ShipmentDocumentModel.fromMap(Map<String, dynamic> map) {
return ShipmentDocumentModel( return ShipmentDocumentModel(
id: map['id'], id: map['id'],
companyId: map['company_id'], companyId: map['company_id'],
ticketId: map['ticket_id'], ticketIds: List<String>.from(map['ticket_ids']),
providerId: map['provider_id'], providerId: map['provider_id'],
destinationLocationId: map['destination_location_id'], destinationLocationId: map['destination_location_id'],
docNumber: map['doc_number'], docNumber: map['doc_number'],
@@ -47,7 +76,7 @@ class ShipmentDocumentModel extends Equatable {
return { return {
if (id != null) 'id': id, if (id != null) 'id': id,
'company_id': companyId, 'company_id': companyId,
'ticket_id': ticketId, 'ticket_ids': ticketIds,
'provider_id': providerId, 'provider_id': providerId,
'destination_location_id': destinationLocationId, 'destination_location_id': destinationLocationId,
'doc_number': docNumber, 'doc_number': docNumber,
@@ -60,5 +89,5 @@ class ShipmentDocumentModel extends Equatable {
} }
@override @override
List<Object?> get props => [id, docNumber, ticketId]; List<Object?> get props => [id, docNumber, ticketIds];
} }

View File

@@ -89,7 +89,7 @@ class _ProviderListScreenState extends State<ProviderListScreen> {
vertical: 8, vertical: 8,
), ),
itemCount: filterChipsWidgets.length, itemCount: filterChipsWidgets.length,
separatorBuilder: (_, __) => const SizedBox(width: 8), separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) => filterChipsWidgets[index], itemBuilder: (context, index) => filterChipsWidgets[index],
), ),
), ),
@@ -125,7 +125,7 @@ class _ProviderListScreenState extends State<ProviderListScreen> {
return ListView.separated( return ListView.separated(
itemCount: displayList.length, itemCount: displayList.length,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final provider = displayList[index]; final provider = displayList[index];

View File

@@ -1,4 +1,4 @@
enum DocumentType { ticket, ddt, invoice } enum DocumentType { ticket, shipment, invoice }
class DocumentSequence { class DocumentSequence {
final String docType; final String docType;

View File

@@ -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<TicketShippingState> {
final TicketsShipmentRepository _repository =
GetIt.I<TicketsShipmentRepository>();
TicketShippingCubit({required List<String> ticketIds})
: super(
TicketShippingState(
// Inizializziamo il modello direttamente nello stato!
document: ShipmentDocumentModel(
companyId: GetIt.I.get<SessionCubit>().state.company!.id!,
ticketIds: ticketIds,
providerId: '', // Sarà riempito alla selezione
destinationLocationId: '', // Sarà riempito alla selezione
docNumber: '',
docDate: DateTime.now(),
),
),
);
Future<void> 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<void> 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(),
),
);
}
}
}

View File

@@ -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<ProviderModel> availableProviders;
final List<ProviderLocationModel> 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<ProviderModel>? availableProviders,
List<ProviderLocationModel>? 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<Object?> get props => [
status,
document,
availableProviders,
availableLocations,
isAutoNumber,
errorMessage,
];
}

View File

@@ -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/blocs/ticket_list_state.dart';
import 'package:flux/features/tickets/models/ticket_model.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/models/ticket_status_extension.dart';
import 'package:flux/features/tickets/ui/widgets/ticket_list.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
class TicketListScreen extends StatefulWidget { class TicketListScreen extends StatefulWidget {
@@ -124,98 +125,9 @@ class _TicketListScreenState extends State<TicketListScreen> {
return const Center(child: Text('Nessun ticket trovato.')); return const Center(child: Text('Nessun ticket trovato.'));
} }
return Stack( return TicketList(
children: [ state: state,
ListView.builder( scrollController: _scrollController,
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<TicketListCubit>()
.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'),
),
],
),
),
),
),
],
); );
}, },
), ),
@@ -266,197 +178,3 @@ class _TicketListScreenState extends State<TicketListScreen> {
); );
} }
} }
// ---------------------------------------------------------
// 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<TicketListCubit>()
.toggleTicketSelection(ticket.id!);
}
},
)
: GestureDetector(
onTap: () {
if (ticket.id != null) {
context
.read<TicketListCubit>()
.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<TicketListCubit>().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<TicketListCubit>().toggleTicketSelection(
ticket.id!,
);
}
},
),
),
],
),
),
);
}
}

View File

@@ -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<TicketListCubit>().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'),
),
],
),
),
),
),
],
);
}
}

View File

@@ -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<TicketListCubit>()
.toggleTicketSelection(ticket.id!);
}
},
)
: GestureDetector(
onTap: () {
if (ticket.id != null) {
context
.read<TicketListCubit>()
.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<TicketListCubit>().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<TicketListCubit>().toggleTicketSelection(
ticket.id!,
);
}
},
),
),
],
),
),
);
}
}