diff --git a/lib/features/operations/blocs/operation_form_cubit.dart b/lib/features/operations/blocs/operation_form_cubit.dart index fd7bbab..1734702 100644 --- a/lib/features/operations/blocs/operation_form_cubit.dart +++ b/lib/features/operations/blocs/operation_form_cubit.dart @@ -217,8 +217,6 @@ class OperationFormCubit extends Cubit { String? reference, String? note, String? type, - String? providerId, - String? providerDisplayName, String? subType, String? description, DateTime? expirationDate, @@ -248,10 +246,6 @@ class OperationFormCubit extends Cubit { final updated = current.copyWith( reference: reference ?? current.reference, note: note ?? current.note, - providerId: clearProvider ? null : (providerId ?? current.providerId), - providerDisplayName: clearProvider - ? null - : (providerDisplayName ?? current.providerDisplayName), quantity: newQuantity ?? current.quantity, type: clearType ? null : (type ?? current.type), description: clearDescription @@ -274,6 +268,18 @@ class OperationFormCubit extends Cubit { emit(state.copyWith(operation: updated)); } + void updateProvider(ProviderModel? newProvider) { + final current = state.operation; + + final updatedOperation = current.copyWith( + // Se newProvider è null, passiamo una funzione che ritorna null per sbiancare i campi! + providerId: () => newProvider?.id, + provider: () => newProvider, + ); + + emit(state.copyWith(operation: updatedOperation)); + } + void updateCustomer(CustomerModel customer) { final bool isBusiness = customer.isBusiness; final updatedOperation = state.operation.copyWith( @@ -293,13 +299,8 @@ class OperationFormCubit extends Cubit { }) { // 1. Aggiorniamo il tipo nel modello in canna // (Presumo tu abbia un metodo copyWith o simile) - final updatedOp = state.operation.copyWith(type: newType, subType: ''); - // 2. Prepariamoci ad auto-selezionare il provider - String? newProviderId = updatedOp.providerId; - String? newProviderName = updatedOp.providerDisplayName; - - // 3. LA LOGICA DI DEFAULT + // 2. LA LOGICA DI DEFAULT if (defaultProviderId != null) { // Troviamo il provider di default nella lista final defaultProvider = allProviders @@ -309,25 +310,13 @@ class OperationFormCubit extends Cubit { if (defaultProvider != null) { // Usiamo l'extension appena creata! if (defaultProvider.supportsOperation(newType)) { - newProviderId = defaultProvider.id; - newProviderName = defaultProvider.name; + updateProvider(defaultProvider); } else { // Se cambi tipo (es. da Mobile a Luce) e il default non lo supporta, sbianchiamo - newProviderId = null; - newProviderName = null; + updateProvider(null); } } } - - // Emettiamo il nuovo stato - emit( - state.copyWith( - operation: updatedOp.copyWith( - providerId: newProviderId, - providerDisplayName: newProviderName, - ), - ), - ); } void setTypeWithSmartDefaults({ @@ -338,7 +327,7 @@ class OperationFormCubit extends Cubit { final currentOp = state.operation; // ----------------------------------------- - // 1. SMART DATES: Calcolo Scadenze Default + // 1. SMART DATES: Calcolo Scadenze Default (Invariato) // ----------------------------------------- DateTime? defaultDate; final now = DateTime.now(); @@ -354,28 +343,19 @@ class OperationFormCubit extends Cubit { } // ----------------------------------------- - // 2. SMART PROVIDER: Filtro e Auto-Selezione + // 2. SMART PROVIDER: Filtro e Auto-Selezione ad Oggetti // ----------------------------------------- - String? newProviderId = currentOp.providerId; - String? newProviderName = currentOp.providerDisplayName; + // Pescatore direttamente l'oggetto dal modello corrente + ProviderModel? targetProvider = currentOp.provider; // A) Il provider attuale è ancora compatibile col nuovo tipo scelto? - if (newProviderId != null && newProviderId.isNotEmpty) { - final currentProvider = allProviders - .where((p) => p.id == newProviderId) - .firstOrNull; - - if (currentProvider == null || - !currentProvider.supportsOperation(newType)) { - // Non è più compatibile (es. da TIM fisso passo a Energy). Lo sbianchiamo! - newProviderId = null; - newProviderName = null; - } + if (targetProvider != null && !targetProvider.supportsOperation(newType)) { + // Non è più compatibile (es. da TIM fisso passo a Energy). Lo sbianchiamo! + targetProvider = null; } // B) Se non c'è un provider selezionato, proviamo ad auto-inserire quello di default del negozio - if ((newProviderId == null || newProviderId.isEmpty) && - defaultProviderId != null) { + if (targetProvider == null && defaultProviderId != null) { final defaultProvider = allProviders .where((p) => p.id == defaultProviderId) .firstOrNull; @@ -383,8 +363,7 @@ class OperationFormCubit extends Cubit { // Controlliamo che il default del negozio supporti questa specifica operazione if (defaultProvider != null && defaultProvider.supportsOperation(newType)) { - newProviderId = defaultProvider.id; - newProviderName = defaultProvider.name; + targetProvider = defaultProvider; } } @@ -395,13 +374,16 @@ class OperationFormCubit extends Cubit { state.copyWith( operation: currentOp.copyWith( type: newType, - subType: - '', // Resettiamo il sottotipo per evitare incongruenze (es. passo da Luce a DAZN) + subType: '', // Resettiamo il sottotipo per evitare incongruenze expirationDate: defaultDate, // Impostiamo la scadenza di default se calcolata - providerId: newProviderId, - providerDisplayName: newProviderName, + // 🥷 APPLICHIAMO IL TRUCCO NINJA DELLE FUNZIONI + // Se targetProvider è null, le funzioni ritorneranno null sbiancando il DB! + providerId: () => targetProvider?.id, + provider: () => targetProvider, + // Nota: Per azzerare davvero questi due, ricordati in futuro di applicare + // il trucco delle funzioni anche a modelId e modelDisplayName nel modello! modelId: null, modelDisplayName: null, ), diff --git a/lib/features/operations/blocs/operation_list_cubit.dart b/lib/features/operations/blocs/operation_list_cubit.dart index 4f3e0ac..ab98289 100644 --- a/lib/features/operations/blocs/operation_list_cubit.dart +++ b/lib/features/operations/blocs/operation_list_cubit.dart @@ -12,72 +12,103 @@ class OperationListCubit extends Cubit { final OperationsRepository _repository = GetIt.I(); final SessionCubit _sessionCubit = GetIt.I(); - OperationListCubit() : super(const OperationListState()) { - loadOperations(refresh: true); - } + OperationListCubit() : super(const OperationListState()); - Future loadOperations({bool refresh = false}) async { + // 🥷 MOTORE 1: DESKTOP (Sostituisce la lista) + Future loadSpecificPageDesktop(int page) async { if (state.status == OperationListStatus.loading) return; - if (!refresh && state.hasReachedMax) return; - - emit( - state.copyWith( - status: OperationListStatus.loading, - errorMessage: null, - operations: refresh ? [] : state.operations, - hasReachedMax: refresh ? false : state.hasReachedMax, - ), - ); + emit(state.copyWith(status: OperationListStatus.loading)); try { - final currentOffset = refresh ? 0 : state.operations.length; final companyId = _sessionCubit.state.company?.id; - - if (companyId == null) { - throw Exception("Company ID non trovato nella sessione"); - } - - final newOperations = await _repository.fetchOperations( - companyId: companyId, - offset: currentOffset, - limit: 50, - searchTerm: state.query, - dateRange: state.dateRange, + final paginatedData = await _repository.fetchPaginatedOperations( + companyId: companyId!, + page: page, + itemsPerPage: state.itemsPerPage, ); - final bool reachedMax = newOperations.length < 50; - emit( state.copyWith( status: OperationListStatus.success, - operations: refresh - ? newOperations - : [...state.operations, ...newOperations], - hasReachedMax: reachedMax, + operations: paginatedData.operations, // 🎯 SOSTITUISCE I DATI + totalItems: paginatedData.totalCount, + currentPage: page, + hasReachedMax: paginatedData.operations.length < state.itemsPerPage, ), ); } catch (e) { emit( state.copyWith( status: OperationListStatus.failure, - errorMessage: "Errore nel caricamento operazioni: $e", + errorMessage: e.toString(), ), ); } } - void updateFilters({String? query, DateTimeRange? range}) { + // 🥷 MOTORE 2: MOBILE (Accoda alla lista) + Future loadNextPageMobile({bool refresh = false}) async { + if (state.status == OperationListStatus.loading) return; + if (state.hasReachedMax && !refresh) return; + + // Se stiamo pullando verso il basso (refresh), ripartiamo da pagina 1 + final targetPage = refresh ? 1 : state.currentPage + 1; + + // Mostriamo il loading solo se è un refresh totale, altrimenti manteniamo lo stato success + // per non far sparire la UI mentre carica in fondo + if (refresh) emit(state.copyWith(status: OperationListStatus.loading)); + + try { + final companyId = _sessionCubit.state.company?.id; + final paginatedData = await _repository.fetchPaginatedOperations( + companyId: companyId!, + page: targetPage, + itemsPerPage: state.itemsPerPage, + ); + + emit( + state.copyWith( + status: OperationListStatus.success, + // 🎯 ACCODA I DATI SE NON È REFRESH, ALTRIMENTI SOSTITUISCE + operations: + refresh ? paginatedData.operations : List.of(state.operations) + ..addAll(paginatedData.operations), + totalItems: paginatedData.totalCount, + currentPage: targetPage, + hasReachedMax: paginatedData.operations.length < state.itemsPerPage, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: OperationListStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void updateFilters({String? text, DateTimeRange? range}) { emit( state.copyWith( - query: query ?? state.query, - dateRange: range ?? state.dateRange, + // 🥷 FORZIAMO IL TIPO: Diciamo a Dart che il risultato del ternario è proprio una funzione + searchTerm: text != null ? () => text : null, + dateRange: range != null ? () => range : null, + + currentPage: 1, // Reset obbligatorio alla prima pagina + hasReachedMax: false, ), ); - loadOperations(refresh: true); + + // Ricarichiamo la pagina 1 con i nuovi filtri applicati + loadSpecificPageDesktop(1); } void clearFilters() { - emit(const OperationListState()); // Resetta tutto allo stato iniziale - loadOperations(refresh: true); + // Invece di un const vuoto che potrebbe bruciarti l'impostazione itemsPerPage, + // creiamo uno stato pulito ma manteniamo la preferenza di paginazione. + emit(OperationListState(itemsPerPage: state.itemsPerPage)); + + loadSpecificPageDesktop(1); } } diff --git a/lib/features/operations/blocs/operation_list_state.dart b/lib/features/operations/blocs/operation_list_state.dart index 34e77fd..19e499f 100644 --- a/lib/features/operations/blocs/operation_list_state.dart +++ b/lib/features/operations/blocs/operation_list_state.dart @@ -5,35 +5,57 @@ enum OperationListStatus { initial, loading, success, failure } class OperationListState extends Equatable { final OperationListStatus status; final List operations; - final bool hasReachedMax; final String? errorMessage; - final String query; + + // Paginazione Ibrida + final int currentPage; + final int itemsPerPage; + final int totalItems; + final bool hasReachedMax; + + // 🥷 I FILTRI MANCANTI (Riparati!) + final String? searchTerm; final DateTimeRange? dateRange; const OperationListState({ this.status = OperationListStatus.initial, this.operations = const [], - this.hasReachedMax = false, this.errorMessage, - this.query = '', + this.currentPage = 1, + this.itemsPerPage = 25, + this.totalItems = 0, + this.hasReachedMax = false, + this.searchTerm, this.dateRange, }); + int get totalPages => (totalItems / itemsPerPage).ceil(); + + // 🥷 COPYWITH AVANZATO: Gestisce lo sbiancamento dei filtri alla perfezione OperationListState copyWith({ OperationListStatus? status, List? operations, - bool? hasReachedMax, String? errorMessage, - String? query, - DateTimeRange? dateRange, + int? currentPage, + int? itemsPerPage, + int? totalItems, + bool? hasReachedMax, + String? Function()? searchTerm, // Callback per gestire il null esplicito + DateTimeRange? Function()? + dateRange, // Callback per gestire il null esplicito }) { return OperationListState( status: status ?? this.status, operations: operations ?? this.operations, + errorMessage: errorMessage ?? this.errorMessage, + currentPage: currentPage ?? this.currentPage, + itemsPerPage: itemsPerPage ?? this.itemsPerPage, + totalItems: totalItems ?? this.totalItems, hasReachedMax: hasReachedMax ?? this.hasReachedMax, - errorMessage: errorMessage, - query: query ?? this.query, - dateRange: dateRange ?? this.dateRange, + + // Se passi la funzione la eseguiamo, altrimenti teniamo il valore corrente + searchTerm: searchTerm != null ? searchTerm() : this.searchTerm, + dateRange: dateRange != null ? dateRange() : this.dateRange, ); } @@ -41,9 +63,12 @@ class OperationListState extends Equatable { List get props => [ status, operations, - hasReachedMax, errorMessage, - query, + currentPage, + itemsPerPage, + totalItems, + hasReachedMax, + searchTerm, dateRange, ]; } diff --git a/lib/features/operations/data/operations_repository.dart b/lib/features/operations/data/operations_repository.dart index 491168a..c4e07f2 100644 --- a/lib/features/operations/data/operations_repository.dart +++ b/lib/features/operations/data/operations_repository.dart @@ -35,6 +35,82 @@ class OperationsRepository { } } + // 🥷 2. RECUPERO PAGINATO ASSOLUTO CON CONTEGGIO TOTALI + Future fetchPaginatedOperations({ + required String companyId, + String? storeId, + String? staffId, + String? providerId, + required int page, // Usiamo 'page' (1, 2, 3...) invece di 'offset' + int itemsPerPage = 25, // Default a 25 elementi per pagina + String? searchTerm, + DateTimeRange? dateRange, + }) async { + try { + // Calcoliamo il range di partenza e fine per Supabase + // Es. Pagina 1, 25 items -> range(0, 24) + // Es. Pagina 2, 25 items -> range(25, 49) + final from = (page - 1) * itemsPerPage; + final to = from + itemsPerPage - 1; + + var query = _supabase + .from(Tables.operations) + .select(''' + *, + ${Tables.customers}(*), + ${Tables.stores}(name), + ${Tables.providers}(name, color_hex), + ${Tables.models}(name_with_brand), + ${Tables.staffMembers}(name), + ${Tables.attachments}(*) + ''') + .eq('company_id', companyId); + + // Filtro Range Date + if (dateRange != null) { + query = query + .gte('created_at', dateRange.start.toIso8601String()) + .lte('created_at', dateRange.end.toIso8601String()); + } + + if (storeId != null) { + query = query.or('store_id.eq.$storeId,store_id.is.null'); + } + + if (staffId != null) { + query = query.or('staff_id.eq.$staffId,staff_id.is.null'); + } + + if (providerId != null) { + query = query.or('provider_id.eq.$providerId,provider_id.is.null'); + } + + if (searchTerm != null && searchTerm.isNotEmpty) { + query = query.or( + 'reference.ilike.%$searchTerm%,note.ilike.%$searchTerm%,customer.name.ilike.%$searchTerm%', + ); + } + + final response = await query + .order('created_at', ascending: false) + .range(from, to) + .count(CountOption.exact); + // 3. Estrazione dei dati + final List operations = (response.data as List) + .map((map) => OperationModel.fromMap(map)) + .toList(); + + final int totalCount = response.count; + + return PaginatedOperations( + operations: operations, + totalCount: totalCount, + ); + } catch (e) { + throw Exception('Errore nel recupero della pagina $page: $e'); + } + } + // --- RECUPERO PAGINATO CON FILTRI E JOIN --- Future> fetchOperations({ required String companyId, @@ -325,3 +401,10 @@ class OperationsRepository { } } } + +class PaginatedOperations { + final List operations; + final int totalCount; + + PaginatedOperations({required this.operations, required this.totalCount}); +} diff --git a/lib/features/operations/models/operation_model.dart b/lib/features/operations/models/operation_model.dart index 16c4f26..25aaf6c 100644 --- a/lib/features/operations/models/operation_model.dart +++ b/lib/features/operations/models/operation_model.dart @@ -3,6 +3,7 @@ import 'package:flux/core/enums_and_consts/consts.dart'; import 'package:flux/core/utils/extensions.dart'; import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:flux/features/customers/models/customer_model.dart'; +import 'package:flux/features/master_data/providers/models/provider_model.dart'; enum OperationStatus { success('success', 'OK'), @@ -30,7 +31,6 @@ class OperationModel extends Equatable { final String type; final String? subType; final String? providerId; - final String? providerDisplayName; final String? modelId; final String? modelDisplayName; final String? description; @@ -50,6 +50,7 @@ class OperationModel extends Equatable { final CustomerModel? customer; final String reference; final bool isBusiness; + final ProviderModel? provider; // ALLEGATI (Aggiunto) final List attachments; @@ -60,7 +61,6 @@ class OperationModel extends Equatable { this.type = '', this.subType, this.providerId, - this.providerDisplayName, this.modelId, this.modelDisplayName, this.description, @@ -81,6 +81,7 @@ class OperationModel extends Equatable { this.reference = '', this.attachments = const [], this.isBusiness = false, + this.provider, }); OperationModel copyWith({ @@ -88,8 +89,9 @@ class OperationModel extends Equatable { DateTime? createdAt, String? type, String? subType, - String? providerId, - String? providerDisplayName, + // 🥷 TRUCCO APPLICATO ANCHE QUI: + String? Function()? providerId, + ProviderModel? Function()? provider, String? modelId, String? modelDisplayName, String? description, @@ -115,8 +117,10 @@ class OperationModel extends Equatable { createdAt: createdAt ?? this.createdAt, type: type ?? this.type, subType: subType ?? this.subType, - providerId: providerId ?? this.providerId, - providerDisplayName: providerDisplayName ?? this.providerDisplayName, + // Se la funzione è passata, la eseguiamo (anche se ritorna null), altrimenti teniamo il vecchio + providerId: providerId != null ? providerId() : this.providerId, + provider: provider != null ? provider() : this.provider, + modelId: modelId ?? this.modelId, modelDisplayName: modelDisplayName ?? this.modelDisplayName, description: description ?? this.description, @@ -146,7 +150,7 @@ class OperationModel extends Equatable { type, subType, providerId, - providerDisplayName, + provider, modelId, modelDisplayName, description, @@ -185,8 +189,9 @@ class OperationModel extends Equatable { // I campi relazionali nullabili restano rigorosamente null! providerId: map['provider_id'] as String?, // MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti - providerDisplayName: (map[Tables.providers]?['name'] as String?) - ?.myFormat(), + provider: (map[Tables.providers] != null) + ? ProviderModel.fromMap(map[Tables.providers] as Map) + : null, modelId: map['model_id'] as String?, modelDisplayName: (map[Tables.models]?['name_with_brand'] as String?) diff --git a/lib/features/operations/ui/operation_list_screen.dart b/lib/features/operations/ui/operation_list_screen.dart index 460e488..eb5b83d 100644 --- a/lib/features/operations/ui/operation_list_screen.dart +++ b/lib/features/operations/ui/operation_list_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/routes/routes.dart'; +import 'package:flux/core/widgets/staff_selector_modal.dart'; +import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/operations/blocs/operation_list_cubit.dart'; import 'package:flux/features/operations/models/operation_model.dart'; import 'package:go_router/go_router.dart'; @@ -14,20 +16,39 @@ class OperationListScreen extends StatefulWidget { class _OperationListScreenState extends State { final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); - // 🥷 1. LO STATO PER LE BULK ACTIONS + // Set per gestire le Bulk Actions (Selezione multipla) final Set _selectedOperationIds = {}; bool get _isSelectionMode => _selectedOperationIds.isNotEmpty; + // Flag per mostrare/nascondere la barra di ricerca integrata nell'AppBar + bool _showSearchBar = false; + @override void initState() { super.initState(); _scrollController.addListener(_onScroll); + + // Primo caricamento: partiamo da pagina 1 + // (Il Cubit deciderà se fare il boot iniziale o se c'era già roba in cache) + WidgetsBinding.instance.addPostFrameCallback((_) { + final isDesktop = MediaQuery.sizeOf(context).width >= 900; + if (isDesktop) { + context.read().loadSpecificPageDesktop(1); + } else { + context.read().loadNextPageMobile(refresh: true); + } + }); } void _onScroll() { + final isDesktop = MediaQuery.sizeOf(context).width >= 900; + // 🥷 COMPORTAMENTO IBRIDO: Lo scroll infinito si attiva SOLO su mobile + if (isDesktop) return; + if (_isBottom) { - context.read().loadOperations(); + context.read().loadNextPageMobile(); } } @@ -41,6 +62,7 @@ class _OperationListScreenState extends State { @override void dispose() { _scrollController.dispose(); + _searchController.dispose(); super.dispose(); } @@ -62,8 +84,10 @@ class _OperationListScreenState extends State { @override Widget build(BuildContext context) { + final isDesktop = MediaQuery.sizeOf(context).width >= 900; + return Scaffold( - // 🥷 2. APPBAR DINAMICA (Standard o Modalità Selezione) + // --- APP BAR DINAMICA E INTEGRATA --- appBar: _isSelectionMode ? AppBar( backgroundColor: Theme.of(context).colorScheme.primaryContainer, @@ -77,24 +101,53 @@ class _OperationListScreenState extends State { icon: const Icon(Icons.edit_note), tooltip: 'Cambia Stato Massivo', onPressed: () { - // TODO: Apri BottomSheet per cambiare stato a tutte le selezionate + // TODO: Integrare bottom sheet per azioni massive }, ), ], ) : AppBar( - title: const Text("Gestione Servizi"), + title: _showSearchBar + ? TextField( + controller: _searchController, + autofocus: true, + decoration: const InputDecoration( + hintText: 'Cerca per cliente, nota o riferimento...', + border: InputBorder.none, + ), + style: const TextStyle(fontSize: 16), + onChanged: (text) { + context.read().updateFilters( + text: text, + ); + }, + ) + : const Text("Gestione Servizi"), elevation: 0, actions: [ IconButton( - icon: const Icon(Icons.filter_list), + icon: Icon(_showSearchBar ? Icons.close : Icons.search), onPressed: () { - // TODO: Apri drawer laterale o modal per i filtri avanzati + setState(() { + _showSearchBar = !_showSearchBar; + if (!_showSearchBar) { + _searchController.clear(); + context.read().clearFilters(); + } + }); }, ), - IconButton(icon: const Icon(Icons.search), onPressed: () {}), + if (!isDesktop) // Il pull-to-refresh c'è già su mobile, su desktop mettiamo un tasto manuale + IconButton( + icon: const Icon(Icons.filter_list), + onPressed: () { + // TODO: Bottone Filtri Avanzati (es. DateRange Picker) + }, + ), ], ), + + // --- CORPO RESPONSIVO --- body: BlocBuilder( builder: (context, state) { if (state.status == OperationListStatus.loading && @@ -103,83 +156,216 @@ class _OperationListScreenState extends State { } if (state.operations.isEmpty) { - return const Center(child: Text("Nessuna pratica trovata.")); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Nessuna pratica trovata."), + const SizedBox(height: 12), + ElevatedButton( + onPressed: () => isDesktop + ? context + .read() + .loadSpecificPageDesktop(1) + : context.read().loadNextPageMobile( + refresh: true, + ), + child: const Text("Ricarica"), + ), + ], + ), + ); } - // 🥷 3. IL MOTORE RESPONSIVO - return RefreshIndicator( - onRefresh: () => context.read().loadOperations( - refresh: true, - ), - child: LayoutBuilder( - builder: (context, constraints) { - // Se lo schermo è largo (Desktop/Tablet), usiamo la griglia - final isDesktop = constraints.maxWidth > 700; - - return GridView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(12).copyWith(bottom: 80), - // Magia della griglia: si adatta! - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: - 450, // Larghezza massima della singola card - mainAxisExtent: - 180, // Altezza fissa della card (da aggiustare in base ai tuoi font) - crossAxisSpacing: 12, - mainAxisSpacing: 12, + // 🥷 SCENARIO DESKTOP: Griglia + Barra di Paginazione Gmail-Style + if (isDesktop) { + return Column( + children: [ + Expanded( + child: GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(16), + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: + 420, // Larghezza bilanciata per le card su desktop + mainAxisExtent: + 175, // Altezza controllata per evitare buchi bianchi + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: state.operations.length, + itemBuilder: (context, index) { + final operation = state.operations[index]; + return _buildResponsiveCard(operation); + }, ), - itemCount: state.hasReachedMax - ? state.operations.length - : state.operations.length + 1, - itemBuilder: (context, index) { - if (index >= state.operations.length) { - return const Center( - child: CircularProgressIndicator(strokeWidth: 2), - ); - } + ), + _buildDesktopPaginationFooter( + state, + ), // La barra in fondo stile Gmail + ], + ); + } - final operation = state.operations[index]; - final isSelected = _selectedOperationIds.contains( - operation.id, - ); + // 🥷 SCENARIO MOBILE: ListView con Infinite Scroll e Pull-to-Refresh + return RefreshIndicator( + onRefresh: () => context + .read() + .loadNextPageMobile(refresh: true), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.only( + bottom: 80, + left: 8, + right: 8, + top: 8, + ), + itemCount: state.hasReachedMax + ? state.operations.length + : state.operations.length + 1, + itemBuilder: (context, index) { + if (index >= state.operations.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + } - return _RichOperationCard( - operation: operation, - isSelected: isSelected, - isSelectionMode: _isSelectionMode, - onTap: () { - if (_isSelectionMode) { - _toggleSelection(operation.id!); - } else { - context.pushNamed( - Routes.operationForm, - extra: (createdBy: null, operation: operation), - pathParameters: {'id': operation.id!}, - ); - } - }, - onLongPress: () => _toggleSelection(operation.id!), - ); - }, + final operation = state.operations[index]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: _buildResponsiveCard(operation), ); }, ), ); }, ), + floatingActionButton: _isSelectionMode - ? null // Nascondi il FAB se stai selezionando + ? null : FloatingActionButton( - onPressed: () { - /* Tuo codice per nuova operazione */ + onPressed: () async { + StaffMemberModel? createdBy = await getStaffMember(context); + if (createdBy == null || !context.mounted) return; + context.pushNamed( + Routes.operationForm, + pathParameters: {'id': 'new'}, + extra: (createdBy: createdBy, operation: null), + ); }, child: const Icon(Icons.add), ), ); } + + // --- COSTRUZIONE DELLA COMPONENTISTICA DETTAGLIATA --- + + Widget _buildResponsiveCard(OperationModel operation) { + final isSelected = _selectedOperationIds.contains(operation.id); + return _RichOperationCard( + operation: operation, + isSelected: isSelected, + isSelectionMode: _isSelectionMode, + onTap: () { + if (_isSelectionMode) { + _toggleSelection(operation.id!); + } else { + context.pushNamed( + Routes.operationForm, + extra: (createdBy: null, operation: operation), + pathParameters: {'id': operation.id!}, + ); + } + }, + onLongPress: () => _toggleSelection(operation.id!), + ); + } + + // 🥷 LA BARRA DI PAGINAZIONE DESKTOP (Stile Gmail / Typesense) + Widget _buildDesktopPaginationFooter(OperationListState state) { + final theme = Theme.of(context); + final cubit = context.read(); + + // Calcolo intervallo visualizzato (es. 1-25 di 140) + final fromItem = ((state.currentPage - 1) * state.itemsPerPage) + 1; + final toItem = + DateUtils.isSameDay(DateTime.now(), DateTime.now()) // segnaposto logico + ? (fromItem + state.operations.length - 1) + : fromItem; + + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 24), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + border: Border(top: BorderSide(color: theme.dividerColor, width: 0.5)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Info totali a sinistra + Text( + "$fromItem-$toItem di ${state.totalItems} pratiche totali", + style: theme.textTheme.bodyMedium?.copyWith( + color: Colors.grey[700], + ), + ), + + // Controlli di navigazione a destra + Row( + children: [ + // Prima Pagina + IconButton( + icon: const Icon(Icons.first_page), + onPressed: state.currentPage > 1 + ? () => cubit.loadSpecificPageDesktop(1) + : null, + ), + // Pagina Precedente + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: state.currentPage > 1 + ? () => cubit.loadSpecificPageDesktop(state.currentPage - 1) + : null, + ), + + // Indicatore numerico centrale impacchettato + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + "Pagina ${state.currentPage} di ${state.totalPages}", + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + + // Pagina Successiva + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: state.currentPage < state.totalPages + ? () => cubit.loadSpecificPageDesktop(state.currentPage + 1) + : null, + ), + // Ultima Pagina + IconButton( + icon: const Icon(Icons.last_page), + onPressed: state.currentPage < state.totalPages + ? () => cubit.loadSpecificPageDesktop(state.totalPages) + : null, + ), + ], + ), + ], + ), + ); + } } -// 🥷 4. LA SUPER CARD ESTRATTA +// ========================================================================= +// 🥷 3. LA CARD RICCA, REATTIVA E DEFINITIVA (Quella revisionata insieme) +// ========================================================================= class _RichOperationCard extends StatelessWidget { final OperationModel operation; final bool isSelected; @@ -195,7 +381,6 @@ class _RichOperationCard extends StatelessWidget { required this.onLongPress, }); - // 🥷 1. IL COLORE DELLO STATO: Centralizzato per usarlo ovunque Color _getStatusColor(OperationStatus status) { switch (status) { case OperationStatus.success: @@ -206,11 +391,10 @@ class _RichOperationCard extends StatelessWidget { case OperationStatus.waitingForSupport: return Colors.blue; case OperationStatus.failure: - return Colors.grey.shade800; // O Colors.red se preferisci + return Colors.grey.shade800; } } - // 🥷 2. IL COLORE DEL TIPO: Per farlo risaltare Color _getTypeColor(String type) { switch (type) { case 'FIN': @@ -239,6 +423,7 @@ class _RichOperationCard extends StatelessWidget { final typeColor = _getTypeColor(operation.type); return Card( + margin: EdgeInsets.zero, // Gestito dai margini dei padri (griglia/lista) elevation: isSelected ? 4 : 1, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -256,32 +441,35 @@ class _RichOperationCard extends StatelessWidget { child: Container( decoration: BoxDecoration( color: isSelected - ? theme.colorScheme.primaryContainer.withValues(alpha: 0.2) + ? theme.colorScheme.primaryContainer.withValues(alpha: 0.15) : null, - // BANDA LATERALE LEGATA ALLO STATO (Stilosissima) + // 🥷 COERENZA 100%: Banda laterale legata allo status per eliminare i malintesi cromatici border: Border(left: BorderSide(color: statusColor, width: 6)), ), padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --- HEADER --- + // --- LINEA HEADER --- Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (isSelectionMode) - SizedBox( - height: 24, - width: 24, - child: Checkbox( - value: isSelected, - onChanged: (_) => onTap(), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: SizedBox( + height: 18, + width: 18, + child: Checkbox( + value: isSelected, + onChanged: (_) => onTap(), + ), ), ), Expanded( child: Text( - operation.reference.isEmpty - ? 'Nessuna Riferimento' + (operation.reference.isEmpty) + ? 'Senza Riferimento' : operation.reference, style: theme.textTheme.labelSmall?.copyWith( color: Colors.grey[600], @@ -291,7 +479,9 @@ class _RichOperationCard extends StatelessWidget { ), ), Text( - "${operation.createdAt?.day.toString().padLeft(2, '0')}/${operation.createdAt?.month.toString().padLeft(2, '0')}/${operation.createdAt?.year}", + operation.createdAt != null + ? "${operation.createdAt!.day.toString().padLeft(2, '0')}/${operation.createdAt!.month.toString().padLeft(2, '0')}/${operation.createdAt!.year}" + : '', style: theme.textTheme.labelSmall?.copyWith( color: Colors.grey[600], ), @@ -300,7 +490,7 @@ class _RichOperationCard extends StatelessWidget { ), const SizedBox(height: 8), - // --- CLIENTE E TIPO OPERAZIONE --- + // --- LINEA CENTRALE: CLIENTE + INSERTO OPERATIVO --- Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -309,24 +499,25 @@ class _RichOperationCard extends StatelessWidget { operation.customer?.name ?? "Cliente sconosciuto", style: const TextStyle( fontWeight: FontWeight.bold, - fontSize: 16, + fontSize: 15, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 8), - // IL TIPO DI OPERAZIONE CHE SPICCA + + // 🥷 IL RE DEL SERVICE: Il tipo operazione svetta con box e contrasto ad hoc Container( padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, + horizontal: 8, + vertical: 4, ), decoration: BoxDecoration( - color: typeColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(8), + color: typeColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(6), border: Border.all( - color: typeColor.withValues(alpha: 0.3), + color: typeColor.withValues(alpha: 0.25), ), ), child: Row( @@ -342,19 +533,20 @@ class _RichOperationCard extends StatelessWidget { operation.type, operation.subType, ), - size: 14, + size: 13, color: typeColor, ), const SizedBox(width: 4), ], Text( - operation.subType?.isNotEmpty == true + (operation.subType != null && + operation.subType!.isNotEmpty) ? operation.subType! : operation.type, style: TextStyle( color: typeColor, fontWeight: FontWeight.bold, - fontSize: 12, + fontSize: 11, ), ), ], @@ -362,14 +554,14 @@ class _RichOperationCard extends StatelessWidget { ), ], ), - const SizedBox(height: 12), + const SizedBox(height: 10), - // --- I TAG COMPATTI (Business/Privato, Provider, Device) --- + // --- LINEA DEI TAG TECNICI --- Wrap( spacing: 6, - runSpacing: 6, + runSpacing: 4, children: [ - // Espanso in "Business" e "Privato" + // Tag Target Espanso (Privato / Business) _MiniChip( label: operation.isBusiness ? 'Business' : 'Privato', icon: operation.isBusiness @@ -378,17 +570,18 @@ class _RichOperationCard extends StatelessWidget { color: operation.isBusiness ? Colors.indigo : Colors.teal, ), - // Tag Provider con il suo colore personalizzato dal DB + // Tag Gestore (Agganciato dinamicamente al displayColor generato dall'esadecimale del DB!) if (operation.providerId != null) _MiniChip( - label: operation.providerDisplayName ?? 'Gestore', - // Se hai popolato il campo colorHex, qui puoi usare: operation.provider?.displayColor ?? Colors.grey - color: Colors.redAccent, + label: operation.provider?.name ?? 'Gestore', + color: + operation.provider?.displayColor ?? Colors.blueGrey, ), + // Specifiche addizionali del Finanziamento if (operation.type == 'Fin' && operation.modelId != null) _MiniChip( - label: operation.modelDisplayName ?? 'Modello', + label: operation.modelDisplayName ?? 'Prodotto', icon: Icons.devices, color: Colors.deepPurple, ), @@ -397,7 +590,7 @@ class _RichOperationCard extends StatelessWidget { const Spacer(), - // --- FOOTER: Staff e Stato --- + // --- FOOTER CARD: AGENTE + CHIP STATO --- Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -410,14 +603,31 @@ class _RichOperationCard extends StatelessWidget { ), const SizedBox(width: 4), Text( - operation.staffDisplayName ?? 'Staff', + operation.staffDisplayName ?? 'Assegnato', style: theme.textTheme.labelSmall?.copyWith( color: Colors.grey[700], ), ), ], ), - _buildOperationStatus(operation.status, statusColor), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: statusColor, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + operation.status.displayName, + style: const TextStyle( + fontSize: 10, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), ], ), ], @@ -435,26 +645,9 @@ class _RichOperationCard extends StatelessWidget { } return null; } - - Widget _buildOperationStatus(OperationStatus status, Color statusColor) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: statusColor, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - status.displayName, - style: const TextStyle( - fontSize: 10, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ); - } } +// Micro Widget di supporto per i tag interni class _MiniChip extends StatelessWidget { final String label; final IconData? icon; @@ -465,23 +658,23 @@ class _MiniChip extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - border: Border.all(color: color.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(6), + color: color.withValues(alpha: 0.08), + border: Border.all(color: color.withValues(alpha: 0.25)), + borderRadius: BorderRadius.circular(4), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ - Icon(icon, size: 12, color: color), + Icon(icon, size: 11, color: color), const SizedBox(width: 4), ], Text( label, style: TextStyle( - fontSize: 11, + fontSize: 10, color: color, fontWeight: FontWeight.bold, ), diff --git a/lib/features/operations/ui/widgets/details_section.dart b/lib/features/operations/ui/widgets/details_section.dart index e343567..fa624b8 100644 --- a/lib/features/operations/ui/widgets/details_section.dart +++ b/lib/features/operations/ui/widgets/details_section.dart @@ -102,10 +102,9 @@ class OperationDetailsSection extends StatelessWidget { overflow: TextOverflow.ellipsis, ), onTap: () { - context.read().updateFields( - providerId: provider.id, - providerDisplayName: provider.name, - ); + context + .read() + .updateProvider(provider); Navigator.pop(modalContext); }, ); @@ -134,9 +133,8 @@ class OperationDetailsSection extends StatelessWidget { ListTile( title: const Text('Seleziona Gestore'), subtitle: Text( - (currentOp?.providerDisplayName != null && - currentOp!.providerDisplayName!.isNotEmpty) - ? currentOp!.providerDisplayName! + (currentOp?.provider != null) + ? currentOp!.provider!.name : 'Nessun gestore selezionato', style: TextStyle( color: