diff --git a/lib/features/tickets/blocs/ticket_list_cubit.dart b/lib/features/tickets/blocs/ticket_list_cubit.dart index 20aa312..5acfe14 100644 --- a/lib/features/tickets/blocs/ticket_list_cubit.dart +++ b/lib/features/tickets/blocs/ticket_list_cubit.dart @@ -10,24 +10,24 @@ class TicketListCubit extends Cubit { static const int _limit = 20; // Paginazione a blocchi di 20 TicketListCubit() : super(const TicketListState()) { - fetchTickets(reset: true); + loadTickets(refresh: true); } /// Recupera i ticket. Se reset = true, svuota la lista e riparte da offset 0. - Future fetchTickets({bool reset = false}) async { + Future loadTickets({bool refresh = false}) async { if (state.isLoading) return; - if (!reset && state.hasReachedMax) return; + if (!refresh && state.hasReachedMax) return; emit( state.copyWith( isLoading: true, errorMessage: '', - tickets: reset ? [] : state.tickets, + tickets: refresh ? [] : state.tickets, ), ); try { - final currentOffset = reset ? 0 : state.tickets.length; + final currentOffset = refresh ? 0 : state.tickets.length; final newTickets = await _repository.fetchStoreTickets( offset: currentOffset, @@ -41,7 +41,7 @@ class TicketListCubit extends Cubit { emit( state.copyWith( - tickets: reset ? newTickets : [...state.tickets, ...newTickets], + tickets: refresh ? newTickets : [...state.tickets, ...newTickets], isLoading: false, hasReachedMax: newTickets.length < _limit, ), @@ -74,6 +74,24 @@ class TicketListCubit extends Cubit { 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.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 ticketIds) { + emit(state.copyWith(selectedTicketIds: ticketIds.toSet())); } } diff --git a/lib/features/tickets/blocs/ticket_list_state.dart b/lib/features/tickets/blocs/ticket_list_state.dart index a2b9636..d61f70d 100644 --- a/lib/features/tickets/blocs/ticket_list_state.dart +++ b/lib/features/tickets/blocs/ticket_list_state.dart @@ -7,6 +7,7 @@ class TicketListState extends Equatable { final bool isLoading; final bool hasReachedMax; final String errorMessage; + final Set selectedTicketIds; // Filtri attivi final String? searchTerm; @@ -20,6 +21,7 @@ class TicketListState extends Equatable { this.isLoading = false, this.hasReachedMax = false, this.errorMessage = '', + this.selectedTicketIds = const {}, this.searchTerm, this.dateRange, this.statusFilter, @@ -32,6 +34,7 @@ class TicketListState extends Equatable { bool? isLoading, bool? hasReachedMax, String? errorMessage, + Set? selectedTicketIds, String? searchTerm, DateTimeRange? dateRange, TicketStatus? statusFilter, @@ -51,6 +54,7 @@ class TicketListState extends Equatable { statusFilter: clearStatus ? null : (statusFilter ?? this.statusFilter), ticketTypeFilter: ticketTypeFilter ?? this.ticketTypeFilter, staffIdFilter: staffIdFilter ?? this.staffIdFilter, + selectedTicketIds: selectedTicketIds ?? this.selectedTicketIds, ); } @@ -60,6 +64,7 @@ class TicketListState extends Equatable { isLoading, hasReachedMax, errorMessage, + selectedTicketIds, searchTerm, dateRange, statusFilter, diff --git a/lib/features/tickets/ui/ticket_form_screen.dart b/lib/features/tickets/ui/ticket_form_screen.dart index 13548a6..656414a 100644 --- a/lib/features/tickets/ui/ticket_form_screen.dart +++ b/lib/features/tickets/ui/ticket_form_screen.dart @@ -304,7 +304,7 @@ class _TicketFormScreenState extends State { } if (state.status == TicketFormStatus.success) { - context.read().fetchTickets(reset: true); + context.read().loadTickets(refresh: true); _showSuccessActions( context, state.ticket, diff --git a/lib/features/tickets/ui/ticket_list_screen.dart b/lib/features/tickets/ui/ticket_list_screen.dart index efffb34..70379e9 100644 --- a/lib/features/tickets/ui/ticket_list_screen.dart +++ b/lib/features/tickets/ui/ticket_list_screen.dart @@ -27,7 +27,7 @@ class _TicketListScreenState extends State { _scrollController.addListener(() { if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { - context.read().fetchTickets(); + context.read().loadTickets(); } }); } @@ -124,25 +124,98 @@ class _TicketListScreenState extends State { return const Center(child: Text('Nessun ticket trovato.')); } - return ListView.builder( - controller: _scrollController, - itemCount: state.hasReachedMax - ? state.tickets.length - : state.tickets.length + 1, - itemBuilder: (context, index) { - // Se siamo all'ultimo elemento e non abbiamo raggiunto il max, mostriamo il loader - if (index >= state.tickets.length) { - return const Center( - child: Padding( - padding: EdgeInsets.all(16.0), - child: CircularProgressIndicator(), - ), - ); - } + 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]; - return _TicketCard(ticket: ticket); - }, + 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'), + ), + ], + ), + ), + ), + ), + ], ); }, ), @@ -161,7 +234,7 @@ class _TicketListScreenState extends State { extra: (createdBy: createdBy, ticket: null), ); if (!context.mounted) return; - context.read().fetchTickets(reset: true); + context.read().loadTickets(refresh: true); }, ), ); @@ -199,31 +272,95 @@ class _TicketListScreenState extends State { // --------------------------------------------------------- class _TicketCard extends StatelessWidget { 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 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, // Serve per tagliare il container laterale con gli angoli della card + clipBehavior: Clip.antiAlias, child: IntrinsicHeight( - // Serve per far sì che il container laterale prenda tutta l'altezza child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // LA STRISCIA COLORATA LATERALE + // 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: [ @@ -272,13 +409,11 @@ class _TicketCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 4), - // MODELLO O TIPO DI INTERVENTO Text( ticket.targetModelName ?? ticket.ticketType.displayValue, style: const TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 4), - // DATA CREAZIONE (Es: 04/05/2026) Text( ticket.createdAt != null ? 'Creato il: ${ticket.createdAt!.day}/${ticket.createdAt!.month}/${ticket.createdAt!.year}' @@ -290,12 +425,32 @@ class _TicketCard extends StatelessWidget { ), ], ), + + // --- 3. GESTIONE DEI TAP PER LA SELEZIONE --- onTap: () { - context.pushNamed( - Routes.ticketForm, - pathParameters: {'id': ticket.id!}, - extra: (ticket: ticket, createdBy: null), - ); + 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/ticket_workspace/ticket_workspace_screen.dart b/lib/features/tickets/ui/ticket_workspace/ticket_workspace_screen.dart index 0e8d16a..b5d17d9 100644 --- a/lib/features/tickets/ui/ticket_workspace/ticket_workspace_screen.dart +++ b/lib/features/tickets/ui/ticket_workspace/ticket_workspace_screen.dart @@ -307,7 +307,7 @@ class TicketWorkspaceScreen extends StatelessWidget { if (context.mounted) { // 4. Avvisiamo il "Vigile Urbano" di ricaricare la lista - context.read().fetchTickets(reset: true); + context.read().loadTickets(refresh: true); // 5. Teletrasporto alla Base context.goNamed(Routes.home);