fdsg
This commit is contained in:
67
lib/features/documents/data/tickets_shipment_repository.dart
Normal file
67
lib/features/documents/data/tickets_shipment_repository.dart
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
enum DocumentType { ticket, ddt, invoice }
|
enum DocumentType { ticket, shipment, invoice }
|
||||||
|
|
||||||
class DocumentSequence {
|
class DocumentSequence {
|
||||||
final String docType;
|
final String docType;
|
||||||
|
|||||||
157
lib/features/tickets/blocs/ticket_shipping_cubit.dart
Normal file
157
lib/features/tickets/blocs/ticket_shipping_cubit.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
lib/features/tickets/blocs/ticket_shipping_state.dart
Normal file
51
lib/features/tickets/blocs/ticket_shipping_state.dart
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
106
lib/features/tickets/ui/widgets/ticket_list.dart
Normal file
106
lib/features/tickets/ui/widgets/ticket_list.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
198
lib/features/tickets/ui/widgets/ticket_list_card.dart
Normal file
198
lib/features/tickets/ui/widgets/ticket_list_card.dart
Normal 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!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user