From 762a7530d588e0529a64d8e3d5971e6120464cf2 Mon Sep 17 00:00:00 2001 From: Mark M2 Macbook Date: Fri, 17 Apr 2026 21:51:37 +0200 Subject: [PATCH] Refactor customer management: migrate from Bloc to Cubit, update state handling, and implement customer search functionality --- .../customers/blocs/customer_bloc.dart | 117 ----------- .../customers/blocs/customer_cubit.dart | 130 ++++++++++++ .../customers/blocs/customer_events.dart | 34 --- .../customers/blocs/customer_state.dart | 7 +- .../customers/ui/customer_search_sheet.dart | 194 ++++++++++++++++++ .../customers/ui/customers_content.dart | 20 +- .../services/blocs/services_cubit.dart | 2 + .../service_form_screen/customer_section.dart | 97 +++++++++ lib/main.dart | 4 +- 9 files changed, 435 insertions(+), 170 deletions(-) delete mode 100644 lib/features/customers/blocs/customer_bloc.dart create mode 100644 lib/features/customers/blocs/customer_cubit.dart delete mode 100644 lib/features/customers/blocs/customer_events.dart create mode 100644 lib/features/customers/ui/customer_search_sheet.dart create mode 100644 lib/features/services/ui/service_form_screen/customer_section.dart diff --git a/lib/features/customers/blocs/customer_bloc.dart b/lib/features/customers/blocs/customer_bloc.dart deleted file mode 100644 index 72a48ad..0000000 --- a/lib/features/customers/blocs/customer_bloc.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flux/features/customers/data/customer_repository.dart'; -import 'package:flux/features/customers/models/customer_model.dart'; -import 'package:get_it/get_it.dart'; - -part 'customer_events.dart'; -part 'customer_state.dart'; - -class CustomerBloc extends Bloc { - final CustomerRepository _repository = GetIt.I(); - - CustomerBloc() : super(const CustomerState()) { - on(_onLoadCustomers); - on(_onCreateCustomer); - on(_onSearchCustomers); - on(_onUpdateCustomer); - } - - Future _onLoadCustomers( - LoadCustomersRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerStatus.loading)); - try { - final customers = await _repository.getCustomers(event.companyId); - emit( - state.copyWith(status: CustomerStatus.success, customers: customers), - ); - } catch (e) { - emit( - state.copyWith( - status: CustomerStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } - - Future _onCreateCustomer( - CreateCustomerRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerStatus.loading)); - try { - final newCustomer = await _repository.createCustomer(event.customer); - - // Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima - final updatedList = List.from(state.customers) - ..insert(0, newCustomer); - - emit( - state.copyWith( - status: CustomerStatus.success, - customers: updatedList, - lastCreatedCustomer: - newCustomer, // Lo passiamo per le Dialog "al volo" - ), - ); - } catch (e) { - emit( - state.copyWith( - status: CustomerStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } - - Future _onUpdateCustomer( - UpdateCustomerRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: CustomerStatus.loading)); - try { - // Qui dovresti aggiungere un metodo updateCustomer nel Repository - // Simile al create ma usando .update().eq('id', customer.id) - final updatedCustomer = await _repository.updateCustomer(event.customer); - - final updatedList = List.from(state.customers); - final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id); - - if (index != -1) { - updatedList[index] = updatedCustomer; - } - - emit( - state.copyWith( - status: CustomerStatus.success, - customers: updatedList, - lastCreatedCustomer: updatedCustomer, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: CustomerStatus.failure, - errorMessage: e.toString(), - ), - ); - } - } - - Future _onSearchCustomers( - SearchCustomersRequested event, - Emitter emit, - ) async { - // Non mettiamo loading per evitare flickering durante la digitazione - try { - final results = await _repository.searchCustomers( - event.companyId, - event.query, - ); - emit(state.copyWith(status: CustomerStatus.success, customers: results)); - } catch (_) {} - } -} diff --git a/lib/features/customers/blocs/customer_cubit.dart b/lib/features/customers/blocs/customer_cubit.dart new file mode 100644 index 0000000..73a9120 --- /dev/null +++ b/lib/features/customers/blocs/customer_cubit.dart @@ -0,0 +1,130 @@ +import 'dart:async'; // Serve per il Timer del debounce +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flux/features/customers/data/customer_repository.dart'; +import 'package:flux/features/customers/models/customer_model.dart'; +import 'package:get_it/get_it.dart'; + +part 'customer_state.dart'; + +class CustomerCubit extends Cubit { + final CustomerRepository _repository = GetIt.I(); + + // Variabile per gestire il debounce della ricerca + Timer? _searchDebounce; + + CustomerCubit() : super(const CustomerState()); + + // --- LETTURA --- + Future loadCustomers(String companyId) async { + emit(state.copyWith(status: CustomerStatus.loading)); + try { + final customers = await _repository.getCustomers(companyId); + emit( + state.copyWith(status: CustomerStatus.success, customers: customers), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + // --- CREAZIONE --- + Future createCustomer(CustomerModel customer) async { + emit(state.copyWith(status: CustomerStatus.loading)); + try { + final newCustomer = await _repository.createCustomer(customer); + + // Aggiorniamo la lista locale aggiungendo il nuovo cliente in cima + final updatedList = List.from(state.customers) + ..insert(0, newCustomer); + + emit( + state.copyWith( + status: CustomerStatus.success, + customers: updatedList, + lastCreatedCustomer: newCustomer, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + // --- AGGIORNAMENTO --- + Future updateCustomer(CustomerModel customer) async { + emit(state.copyWith(status: CustomerStatus.loading)); + try { + final updatedCustomer = await _repository.updateCustomer(customer); + + final updatedList = List.from(state.customers); + final index = updatedList.indexWhere((c) => c.id == updatedCustomer.id); + + if (index != -1) { + updatedList[index] = updatedCustomer; + } + + emit( + state.copyWith( + status: CustomerStatus.success, + customers: updatedList, + lastCreatedCustomer: + updatedCustomer, // Utile se modifichi un cliente appena creato + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + // --- RICERCA CON DEBOUNCE --- + void searchCustomers(String companyId, String query) { + // 1. Se c'è già una ricerca in attesa (l'utente sta digitando veloce), la annulliamo + if (_searchDebounce?.isActive ?? false) _searchDebounce!.cancel(); + + // 2. Facciamo partire un timer di 400 millisecondi + _searchDebounce = Timer(const Duration(milliseconds: 400), () async { + // Se cancella tutto e la query è vuota, ricarichiamo la lista base + if (query.trim().isEmpty) { + await loadCustomers(companyId); + return; + } + + // Nessun "loading" state qui, per evitare sfarfallii visivi mentre si scrive + try { + final results = await _repository.searchCustomers(companyId, query); + emit( + state.copyWith(status: CustomerStatus.success, customers: results), + ); + } catch (e) { + emit( + state.copyWith( + status: CustomerStatus.failure, + errorMessage: e.toString(), + ), + ); + } + }); + } + + // Pulizia della memoria quando il Cubit viene distrutto + @override + Future close() { + _searchDebounce?.cancel(); + return super.close(); + } +} diff --git a/lib/features/customers/blocs/customer_events.dart b/lib/features/customers/blocs/customer_events.dart deleted file mode 100644 index baec73f..0000000 --- a/lib/features/customers/blocs/customer_events.dart +++ /dev/null @@ -1,34 +0,0 @@ -part of 'customer_bloc.dart'; - -abstract class CustomerEvent extends Equatable { - const CustomerEvent(); - @override - List get props => []; -} - -// Carica tutti i clienti dell'azienda -class LoadCustomersRequested extends CustomerEvent { - final String companyId; - const LoadCustomersRequested(this.companyId); -} - -// Crea un cliente (usato sia dalla lista che dalla Dialog operazioni) -class CreateCustomerRequested extends CustomerEvent { - final CustomerModel customer; - const CreateCustomerRequested(this.customer); -} - -// Ricerca in tempo reale -class SearchCustomersRequested extends CustomerEvent { - final String companyId; - final String query; - const SearchCustomersRequested(this.companyId, this.query); -} - -class UpdateCustomerRequested extends CustomerEvent { - final CustomerModel customer; - const UpdateCustomerRequested(this.customer); - - @override - List get props => [customer]; -} diff --git a/lib/features/customers/blocs/customer_state.dart b/lib/features/customers/blocs/customer_state.dart index 43134bf..c8789bd 100644 --- a/lib/features/customers/blocs/customer_state.dart +++ b/lib/features/customers/blocs/customer_state.dart @@ -1,12 +1,11 @@ -part of 'customer_bloc.dart'; +part of 'customer_cubit.dart'; enum CustomerStatus { initial, loading, success, failure } class CustomerState extends Equatable { final CustomerStatus status; - final List customers; // Per la lista generale - final CustomerModel? - lastCreatedCustomer; // <--- Fondamentale per la Dialog "al volo" + final List customers; + final CustomerModel? lastCreatedCustomer; final String? errorMessage; const CustomerState({ diff --git a/lib/features/customers/ui/customer_search_sheet.dart b/lib/features/customers/ui/customer_search_sheet.dart new file mode 100644 index 0000000..c3f6d06 --- /dev/null +++ b/lib/features/customers/ui/customer_search_sheet.dart @@ -0,0 +1,194 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/customers/blocs/customer_cubit.dart'; +import 'package:flux/features/services/blocs/services_cubit.dart'; + +class CustomerSearchSheet extends StatefulWidget { + const CustomerSearchSheet({super.key}); + + @override + State createState() => _CustomerSearchSheetState(); +} + +class _CustomerSearchSheetState extends State { + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + // Opzionale ma consigliato: carica i clienti recenti appena si apre la modale, + // così l'utente non vede una schermata vuota prima di cercare. + // context.read().loadCustomers(query: ''); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + void _onSearchChanged(String query) { + // Comunichiamo al Cubit dei clienti di fare la query su Supabase + // (Consiglio Pro: nel Cubit, metti un "debounce" di 300ms su questa chiamata + // per non bombardare Supabase a ogni singola lettera digitata!) + // context.read().searchCustomers(query); + } + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // --- HEADER --- + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + "Trova Cliente", + style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + tooltip: "Chiudi", + ), + ], + ), + const SizedBox(height: 16), + + // --- BARRA DI RICERCA --- + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: "Cerca per nome, cognome o CF...", + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + suffixIcon: IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearchChanged(""); + }, + ), + ), + onChanged: _onSearchChanged, + ), + const SizedBox(height: 16), + + // --- TASTO NUOVO CLIENTE --- + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + // TODO: Naviga alla pagina "Crea Cliente". + }, + icon: const Icon(Icons.person_add), + label: const Text("Crea Nuovo Cliente"), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + const SizedBox(height: 24), + + // --- LISTA RISULTATI CON BLOC BUILDER --- + const Text( + "Risultati", + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey), + ), + const SizedBox(height: 8), + + Expanded( + // AGGANCIO AL CUBIT REALE + child: BlocBuilder( + builder: (context, state) { + // 1. Stato di caricamento + if (state.status == CustomerStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } + + // 2. Nessun risultato trovato + if (state.customers.isEmpty) { + return const Center( + child: Text( + "Nessun cliente trovato.\nProva a cambiare i termini di ricerca.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ); + } + + // 3. Mostriamo la lista vera + return ListView.separated( + itemCount: state.customers.length, + separatorBuilder: (context, index) => + const Divider(height: 1), + itemBuilder: (context, index) { + final customer = state.customers[index]; + // Assumo che il tuo CustomerModel abbia le proprietà name e surname. + // Adatta queste variabili al tuo modello reale! + final displayName = customer.nome.trim(); + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + foregroundColor: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + // Mostra l'iniziale + child: Text( + displayName.isNotEmpty + ? displayName[0].toUpperCase() + : "?", + ), + ), + title: Text( + displayName, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + subtitle: Text(customer.email), + trailing: const Icon( + Icons.check_circle_outline, + color: Colors.grey, + ), + onTap: () { + // Salviamo l'ID e il nome formattato nel form dei servizi + context.read().updateField( + customerId: customer.id, + customerDisplayName: displayName, + ); + + // Chiudiamo la modale + Navigator.pop(context); + }, + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/customers/ui/customers_content.dart b/lib/features/customers/ui/customers_content.dart index eea3bd4..1348e65 100644 --- a/lib/features/customers/ui/customers_content.dart +++ b/lib/features/customers/ui/customers_content.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_bloc.dart'; import 'package:flux/core/theme/theme.dart'; -import 'package:flux/features/customers/blocs/customer_bloc.dart'; +import 'package:flux/features/customers/blocs/customer_cubit.dart'; import 'package:flux/features/customers/models/customer_model.dart'; import 'package:flux/features/customers/ui/customer_form.dart'; import 'package:go_router/go_router.dart'; @@ -26,16 +26,14 @@ class _CustomersContentState extends State { void _loadInitialCustomers() { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().add(LoadCustomersRequested(companyId)); + context.read().loadCustomers(companyId); } } void _onSearch(String query) { final companyId = context.read().state.company?.id; if (companyId != null) { - context.read().add( - SearchCustomersRequested(companyId, query), - ); + context.read().searchCustomers(companyId, query); } } @@ -57,16 +55,12 @@ class _CustomersContentState extends State { if (customer == null) { // CASO NUOVO: Iniettiamo il companyId e inviamo l'evento create - context.read().add( - CreateCustomerRequested( - customerFromForm.copyWith(companyId: companyId), - ), + context.read().createCustomer( + customerFromForm.copyWith(companyId: companyId), ); } else { // CASO MODIFICA: L'ID e il companyId sono già nel modello - context.read().add( - UpdateCustomerRequested(customerFromForm), - ); + context.read().updateCustomer(customerFromForm); } Navigator.pop(dialogContext); }, @@ -125,7 +119,7 @@ class _CustomersContentState extends State { // LISTA CLIENTI Expanded( - child: BlocBuilder( + child: BlocBuilder( builder: (context, state) { if (state.status == CustomerStatus.loading && state.customers.isEmpty) { diff --git a/lib/features/services/blocs/services_cubit.dart b/lib/features/services/blocs/services_cubit.dart index 966bdb9..83a3481 100644 --- a/lib/features/services/blocs/services_cubit.dart +++ b/lib/features/services/blocs/services_cubit.dart @@ -124,6 +124,7 @@ class ServicesCubit extends Cubit { bool? isBozza, bool? resultOk, String? customerId, + String? customerDisplayName, }) { if (state.currentService == null) return; @@ -138,6 +139,7 @@ class ServicesCubit extends Cubit { isBozza: isBozza, resultOk: resultOk, customerId: customerId, + customerDisplayName: customerDisplayName, ); emit(state.copyWith(currentService: updated)); diff --git a/lib/features/services/ui/service_form_screen/customer_section.dart b/lib/features/services/ui/service_form_screen/customer_section.dart new file mode 100644 index 0000000..3ae401d --- /dev/null +++ b/lib/features/services/ui/service_form_screen/customer_section.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/features/customers/ui/customer_search_sheet.dart'; +import 'package:flux/features/services/models/service_model.dart'; + +class CustomerSection extends StatelessWidget { + final ServiceModel service; + + const CustomerSection({super.key, required this.service}); + + void _openCustomerSearch(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (modalContext) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(modalContext).viewInsets.bottom, + ), + // La modale di ricerca + child: const CustomerSearchSheet(), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + // Niente BlocBuilder qui! Leggiamo solo la variabile 'service' + final hasCustomer = service.customerId != null; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.person, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + "Dati Cliente", + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + const SizedBox(height: 16), + + if (!hasCustomer) + Center( + child: ElevatedButton.icon( + onPressed: () => _openCustomerSearch(context), + icon: const Icon(Icons.search), + label: const Text("Seleziona o Crea Cliente"), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ) + else + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + service.customerDisplayName ?? "Cliente Selezionato", + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + TextButton.icon( + onPressed: () => _openCustomerSearch(context), + icon: const Icon(Icons.edit, size: 18), + label: const Text("Cambia"), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index b8b8cb6..63891cd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,7 +8,7 @@ import 'package:flux/core/theme/bloc/theme_bloc.dart'; import 'package:flux/features/auth/bloc/auth_bloc.dart'; import 'package:flux/features/company/bloc/company_bloc.dart'; import 'package:flux/features/company/data/company_repository.dart'; -import 'package:flux/features/customers/blocs/customer_bloc.dart'; +import 'package:flux/features/customers/blocs/customer_cubit.dart'; import 'package:flux/features/customers/data/customer_repository.dart'; import 'package:flux/features/master_data/products/blocs/product_cubit.dart'; import 'package:flux/features/master_data/products/data/product_repository.dart'; @@ -93,7 +93,7 @@ class _FluxAppState extends State { BlocProvider( create: (_) => StoreCubit(context.read())..loadStores(), ), - BlocProvider(create: (_) => CustomerBloc()), + BlocProvider(create: (_) => CustomerCubit()), BlocProvider( create: (context) => ProductCubit(context.read()), ),