This commit is contained in:
2026-05-15 13:32:34 +02:00
parent f19f19a279
commit f4a8314978
5 changed files with 219 additions and 41 deletions

View File

@@ -10,24 +10,24 @@ class TicketListCubit extends Cubit<TicketListState> {
static const int _limit = 20; // Paginazione a blocchi di 20 static const int _limit = 20; // Paginazione a blocchi di 20
TicketListCubit() : super(const TicketListState()) { TicketListCubit() : super(const TicketListState()) {
fetchTickets(reset: true); loadTickets(refresh: true);
} }
/// Recupera i ticket. Se reset = true, svuota la lista e riparte da offset 0. /// Recupera i ticket. Se reset = true, svuota la lista e riparte da offset 0.
Future<void> fetchTickets({bool reset = false}) async { Future<void> loadTickets({bool refresh = false}) async {
if (state.isLoading) return; if (state.isLoading) return;
if (!reset && state.hasReachedMax) return; if (!refresh && state.hasReachedMax) return;
emit( emit(
state.copyWith( state.copyWith(
isLoading: true, isLoading: true,
errorMessage: '', errorMessage: '',
tickets: reset ? [] : state.tickets, tickets: refresh ? [] : state.tickets,
), ),
); );
try { try {
final currentOffset = reset ? 0 : state.tickets.length; final currentOffset = refresh ? 0 : state.tickets.length;
final newTickets = await _repository.fetchStoreTickets( final newTickets = await _repository.fetchStoreTickets(
offset: currentOffset, offset: currentOffset,
@@ -41,7 +41,7 @@ class TicketListCubit extends Cubit<TicketListState> {
emit( emit(
state.copyWith( state.copyWith(
tickets: reset ? newTickets : [...state.tickets, ...newTickets], tickets: refresh ? newTickets : [...state.tickets, ...newTickets],
isLoading: false, isLoading: false,
hasReachedMax: newTickets.length < _limit, hasReachedMax: newTickets.length < _limit,
), ),
@@ -74,6 +74,24 @@ class TicketListCubit extends Cubit<TicketListState> {
clearStatus: clearStatus, clearStatus: clearStatus,
), ),
); );
fetchTickets(reset: true); // Applica i filtri e ricarica loadTickets(refresh: true); // Applica i filtri e ricarica
}
void toggleTicketSelection(String ticketId) {
final currentSelection = Set<String>.from(state.selectedTicketIds);
if (currentSelection.contains(ticketId)) {
currentSelection.remove(ticketId);
} else {
currentSelection.add(ticketId);
}
emit(state.copyWith(selectedTicketIds: currentSelection));
}
void clearSelection() {
emit(state.copyWith(selectedTicketIds: {}));
}
void selectAll(List<String> ticketIds) {
emit(state.copyWith(selectedTicketIds: ticketIds.toSet()));
} }
} }

View File

@@ -7,6 +7,7 @@ class TicketListState extends Equatable {
final bool isLoading; final bool isLoading;
final bool hasReachedMax; final bool hasReachedMax;
final String errorMessage; final String errorMessage;
final Set<String> selectedTicketIds;
// Filtri attivi // Filtri attivi
final String? searchTerm; final String? searchTerm;
@@ -20,6 +21,7 @@ class TicketListState extends Equatable {
this.isLoading = false, this.isLoading = false,
this.hasReachedMax = false, this.hasReachedMax = false,
this.errorMessage = '', this.errorMessage = '',
this.selectedTicketIds = const {},
this.searchTerm, this.searchTerm,
this.dateRange, this.dateRange,
this.statusFilter, this.statusFilter,
@@ -32,6 +34,7 @@ class TicketListState extends Equatable {
bool? isLoading, bool? isLoading,
bool? hasReachedMax, bool? hasReachedMax,
String? errorMessage, String? errorMessage,
Set<String>? selectedTicketIds,
String? searchTerm, String? searchTerm,
DateTimeRange? dateRange, DateTimeRange? dateRange,
TicketStatus? statusFilter, TicketStatus? statusFilter,
@@ -51,6 +54,7 @@ class TicketListState extends Equatable {
statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter), statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter),
ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter, ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter,
staffIdFilter: staffIdFilter ?? this.staffIdFilter, staffIdFilter: staffIdFilter ?? this.staffIdFilter,
selectedTicketIds: selectedTicketIds ?? this.selectedTicketIds,
); );
} }
@@ -60,6 +64,7 @@ class TicketListState extends Equatable {
isLoading, isLoading,
hasReachedMax, hasReachedMax,
errorMessage, errorMessage,
selectedTicketIds,
searchTerm, searchTerm,
dateRange, dateRange,
statusFilter, statusFilter,

View File

@@ -304,7 +304,7 @@ class _TicketFormScreenState extends State<TicketFormScreen> {
} }
if (state.status == TicketFormStatus.success) { if (state.status == TicketFormStatus.success) {
context.read<TicketListCubit>().fetchTickets(reset: true); context.read<TicketListCubit>().loadTickets(refresh: true);
_showSuccessActions( _showSuccessActions(
context, context,
state.ticket, state.ticket,

View File

@@ -27,7 +27,7 @@ class _TicketListScreenState extends State<TicketListScreen> {
_scrollController.addListener(() { _scrollController.addListener(() {
if (_scrollController.position.pixels >= if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) { _scrollController.position.maxScrollExtent - 200) {
context.read<TicketListCubit>().fetchTickets(); context.read<TicketListCubit>().loadTickets();
} }
}); });
} }
@@ -124,7 +124,9 @@ class _TicketListScreenState extends State<TicketListScreen> {
return const Center(child: Text('Nessun ticket trovato.')); return const Center(child: Text('Nessun ticket trovato.'));
} }
return ListView.builder( return Stack(
children: [
ListView.builder(
controller: _scrollController, controller: _scrollController,
itemCount: state.hasReachedMax itemCount: state.hasReachedMax
? state.tickets.length ? state.tickets.length
@@ -141,8 +143,79 @@ class _TicketListScreenState extends State<TicketListScreen> {
} }
final ticket = state.tickets[index]; final ticket = state.tickets[index];
return _TicketCard(ticket: ticket); 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'),
),
],
),
),
),
),
],
); );
}, },
), ),
@@ -161,7 +234,7 @@ class _TicketListScreenState extends State<TicketListScreen> {
extra: (createdBy: createdBy, ticket: null), extra: (createdBy: createdBy, ticket: null),
); );
if (!context.mounted) return; if (!context.mounted) return;
context.read<TicketListCubit>().fetchTickets(reset: true); context.read<TicketListCubit>().loadTickets(refresh: true);
}, },
), ),
); );
@@ -199,31 +272,95 @@ class _TicketListScreenState extends State<TicketListScreen> {
// --------------------------------------------------------- // ---------------------------------------------------------
class _TicketCard extends StatelessWidget { class _TicketCard extends StatelessWidget {
final TicketModel ticket; final TicketModel ticket;
final bool isSelected;
final bool isSelectionMode;
final bool isDesktop;
const _TicketCard({required this.ticket}); const _TicketCard({
super.key, // <-- Buona pratica aggiungere il super.key
required this.ticket,
required this.isSelected,
required this.isSelectionMode,
required this.isDesktop,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final statusColor = ticket.ticketStatus.color; final statusColor = ticket.ticketStatus.color;
final statusIcon = ticket.ticketStatus.icon; 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( 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), margin: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
clipBehavior: Clip clipBehavior: Clip.antiAlias,
.antiAlias, // Serve per tagliare il container laterale con gli angoli della card
child: IntrinsicHeight( child: IntrinsicHeight(
// Serve per far sì che il container laterale prenda tutta l'altezza
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// LA STRISCIA COLORATA LATERALE // LA STRISCIA COLORATA LATERALE (Intoccabile, è bellissima)
Container(width: 6, color: statusColor), 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( Expanded(
child: ListTile( child: ListTile(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, horizontal: 16.0,
vertical: 8.0, vertical: 8.0,
), ),
// ----------------------------------------
title: Row( title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@@ -272,13 +409,11 @@ class _TicketCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const SizedBox(height: 4), const SizedBox(height: 4),
// MODELLO O TIPO DI INTERVENTO
Text( Text(
ticket.targetModelName ?? ticket.ticketType.displayValue, ticket.targetModelName ?? ticket.ticketType.displayValue,
style: const TextStyle(fontWeight: FontWeight.w500), style: const TextStyle(fontWeight: FontWeight.w500),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// DATA CREAZIONE (Es: 04/05/2026)
Text( Text(
ticket.createdAt != null ticket.createdAt != null
? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}' ? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}'
@@ -290,12 +425,32 @@ class _TicketCard extends StatelessWidget {
), ),
], ],
), ),
// --- 3. GESTIONE DEI TAP PER LA SELEZIONE ---
onTap: () { 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( context.pushNamed(
Routes.ticketForm, Routes.ticketForm,
pathParameters: {'id': ticket.id!}, pathParameters: {'id': ticket.id!},
extra: (ticket: ticket, createdBy: null), 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

@@ -307,7 +307,7 @@ class TicketWorkspaceScreen extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
// 4. Avvisiamo il "Vigile Urbano" di ricaricare la lista // 4. Avvisiamo il "Vigile Urbano" di ricaricare la lista
context.read<TicketListCubit>().fetchTickets(reset: true); context.read<TicketListCubit>().loadTickets(refresh: true);
// 5. Teletrasporto alla Base // 5. Teletrasporto alla Base
context.goNamed(Routes.home); context.goNamed(Routes.home);